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 project_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 the public IC, whose root trust anchor is compiled into Canic and cannot be enrolled"
99 )]
100 PublicIcEnrollment { 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.project_root, options.environment)?
176 == BuildNetwork::Ic
177 {
178 return Err(NetworkIdentityError::PublicIcEnrollment {
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.project_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 project_root: &Path,
264 environment: &str,
265) -> Result<CanonicalNetworkId, NetworkIdentityError> {
266 validate_environment_name(environment)?;
267 let build_network = resolve_icp_build_network_from_root(project_root, environment)?;
268 let profile_path = environment_profile_path(project_root, environment);
269
270 if build_network == BuildNetwork::Ic {
271 let expected = CanonicalNetworkId::public_ic();
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(project_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
306fn validate_existing_authority(
307 paths: &NetworkPaths,
308 expected_digest: [u8; 32],
309 observed_network_id: CanonicalNetworkId,
310 root_key: Option<&[u8]>,
311 enrollment: Option<&NetworkEnrollmentRecord>,
312 profile: Option<&EnvironmentNetworkProfile>,
313) -> Result<(), NetworkIdentityError> {
314 if enrollment.is_some() && root_key.is_none() {
315 return Err(NetworkIdentityError::ContradictoryAuthority {
316 reason: format!(
317 "{} exists without {}",
318 paths.enrollment.display(),
319 paths.root_key.display()
320 ),
321 });
322 }
323 if profile.is_some() && (root_key.is_none() || enrollment.is_none()) {
324 return Err(NetworkIdentityError::ContradictoryAuthority {
325 reason: format!(
326 "{} is visible without a complete authority",
327 paths.profile.display()
328 ),
329 });
330 }
331 if let Some(enrollment) = enrollment {
332 validate_environment_name(&enrollment.source_profile)?;
333 validate_complete_authority(
334 paths.canonical_network_id,
335 expected_digest,
336 observed_network_id,
337 enrollment,
338 paths,
339 )?;
340 }
341 Ok(())
342}
343
344fn validate_complete_authority(
345 canonical_network_id: CanonicalNetworkId,
346 root_key_digest: [u8; 32],
347 observed_network_id: CanonicalNetworkId,
348 enrollment: &NetworkEnrollmentRecord,
349 paths: &NetworkPaths,
350) -> Result<(), NetworkIdentityError> {
351 if enrollment.root_key_digest != root_key_digest {
352 return Err(NetworkIdentityError::ContradictoryAuthority {
353 reason: format!(
354 "{} does not match the exact root trust anchor",
355 paths.enrollment.display()
356 ),
357 });
358 }
359 if observed_network_id != canonical_network_id {
360 return Err(NetworkIdentityError::ContradictoryAuthority {
361 reason: format!(
362 "{} derives network {observed_network_id}, not {canonical_network_id}",
363 paths.root_key.display()
364 ),
365 });
366 }
367 Ok(())
368}
369
370pub(crate) fn validate_environment_name(name: &str) -> Result<(), NetworkIdentityError> {
371 if !name.is_empty()
372 && name
373 .bytes()
374 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
375 {
376 Ok(())
377 } else {
378 Err(NetworkIdentityError::InvalidEnvironmentName {
379 name: name.to_string(),
380 })
381 }
382}
383
384fn sha256_digest(bytes: &[u8]) -> [u8; 32] {
385 Sha256::digest(bytes).into()
386}
387
388fn parse_fingerprint(value: &str) -> Result<[u8; 32], NetworkIdentityError> {
389 if value.len() != 64
390 || !value
391 .bytes()
392 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
393 {
394 return Err(NetworkIdentityError::InvalidFingerprint);
395 }
396 let mut digest = [0; 32];
397 for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
398 digest[index] = (decode_nibble(pair[0]) << 4) | decode_nibble(pair[1]);
399 }
400 Ok(digest)
401}
402
403fn decode_nibble(byte: u8) -> u8 {
404 match byte {
405 b'0'..=b'9' => byte - b'0',
406 b'a'..=b'f' => byte - b'a' + 10,
407 _ => unreachable!("fingerprint was validated before decoding"),
408 }
409}
410
411fn encode_digest(digest: [u8; 32]) -> String {
412 digest
413 .iter()
414 .fold(String::with_capacity(64), |mut encoded, byte| {
415 write!(encoded, "{byte:02x}").expect("writing to a String cannot fail");
416 encoded
417 })
418}
419
420fn create_new(path: &Path, bytes: &[u8]) -> Result<(), NetworkIdentityError> {
421 match create_new_bytes_with_parents(path, bytes) {
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) {
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) {
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_regular_bytes(path) {
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(RegularFileReadError::NotRegular) => Err(non_regular_file_error(path, purpose)),
582 Err(RegularFileReadError::Io(source)) => Err(NetworkIdentityError::Io {
583 path: path.to_path_buf(),
584 source,
585 }),
586 #[cfg(not(unix))]
587 Err(RegularFileReadError::UnsupportedPlatform) => Err(
588 NetworkIdentityError::UnsupportedPlatform(std::env::consts::OS),
589 ),
590 }
591}
592
593fn non_regular_file_error(path: &Path, purpose: FilePurpose) -> NetworkIdentityError {
594 match purpose {
595 FilePurpose::EnrollmentInput => NetworkIdentityError::RootKeyNotRegular {
596 path: path.to_path_buf(),
597 },
598 FilePurpose::Authority => NetworkIdentityError::AuthorityNotRegular {
599 path: path.to_path_buf(),
600 },
601 FilePurpose::Profile => NetworkIdentityError::ProfileNotRegular {
602 path: path.to_path_buf(),
603 },
604 }
605}
606
607fn environment_profile_path(project_root: &Path, environment: &str) -> PathBuf {
608 project_root
609 .join(CANIC_STATE_DIRECTORY)
610 .join(ENVIRONMENT_PROFILES_DIRECTORY)
611 .join(environment)
612 .join(NETWORK_PROFILE_FILE)
613}
614
615struct NetworkPaths {
616 canonical_network_id: CanonicalNetworkId,
617 authority_directory: PathBuf,
618 root_key: PathBuf,
619 enrollment: PathBuf,
620 profile: PathBuf,
621}
622
623impl NetworkPaths {
624 fn new(
625 project_root: &Path,
626 environment: &str,
627 canonical_network_id: CanonicalNetworkId,
628 ) -> Self {
629 let authority_directory = project_root
630 .join(CANIC_STATE_DIRECTORY)
631 .join(NETWORKS_DIRECTORY)
632 .join(canonical_network_id.to_string());
633 Self {
634 canonical_network_id,
635 root_key: authority_directory.join(ROOT_KEY_RELATIVE_PATH),
636 enrollment: authority_directory.join(ENROLLMENT_FILE),
637 profile: environment_profile_path(project_root, environment),
638 authority_directory,
639 }
640 }
641}
642
643mod digest_hex {
644 use super::*;
645
646 pub fn serialize<S>(digest: &[u8; 32], serializer: S) -> Result<S::Ok, S::Error>
647 where
648 S: Serializer,
649 {
650 serializer.serialize_str(&encode_digest(*digest))
651 }
652
653 pub fn deserialize<'de, D>(deserializer: D) -> Result<[u8; 32], D::Error>
654 where
655 D: Deserializer<'de>,
656 {
657 let value = String::deserialize(deserializer)?;
658 parse_fingerprint(&value).map_err(de::Error::custom)
659 }
660}