provider_envelope.rs (56215B)
1 //! Sealed encrypted-file identity provider boundary. 2 3 use core::fmt; 4 use std::error::Error; 5 use std::path::Path; 6 use std::sync::Mutex; 7 use std::sync::atomic::{AtomicBool, Ordering}; 8 9 use chacha20poly1305::aead::{Aead, KeyInit, Payload}; 10 use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce}; 11 use nostr::{Keys, SecretKey}; 12 use radroots_secrets::context::{ 13 EnvelopeContext, EnvelopePurpose, EnvelopeSubject, PayloadSchemaId, 14 }; 15 use radroots_secrets::envelope::{ 16 ENVELOPE_MAX_BYTES, ENVELOPE_VERSION, Nonce, SealMaterial, SealRequest, 17 }; 18 use radroots_secrets::error::Operation; 19 use radroots_secrets::id::{BackendKind, KeyVersion}; 20 use radroots_secrets::wrapping::{ 21 BoxFuture, SecretMaterial, UnwrapRequest, WrapRequest, WrappedSecret, 22 }; 23 use radroots_secrets::{EncryptedEnvelope, KeyWrapping, SecretId, SecretRef}; 24 use zeroize::Zeroizing; 25 26 use crate::{MycProviderBinding, MycProviderKind, MycProviderPublicIdentity}; 27 28 /// Exact Myc encrypted-identity envelope contract version. 29 pub const MYC_ENCRYPTED_IDENTITY_ENVELOPE_CONTRACT_VERSION: u32 = 1; 30 /// Hard encoded-envelope cap inherited from the source-locked secrets crate. 31 pub const MYC_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES: usize = ENVELOPE_MAX_BYTES; 32 /// Myc state backups never contain encrypted identity envelopes. 33 pub const MYC_ENCRYPTED_IDENTITY_BACKUP_INCLUDED: bool = false; 34 35 const IDENTITY_SECRET_BYTES: usize = 32; 36 const WRAPPING_CREDENTIAL_BYTES: usize = 32; 37 const NONCE_BYTES: usize = 24; 38 const WRAPPED_KEY_MAGIC: [u8; 4] = *b"MYWK"; 39 const WRAPPED_KEY_VERSION: u8 = 1; 40 const WRAPPED_KEY_CIPHERTEXT_BYTES: usize = IDENTITY_SECRET_BYTES + 16; 41 const WRAPPED_KEY_BYTES: usize = 42 WRAPPED_KEY_MAGIC.len() + 1 + NONCE_BYTES + WRAPPED_KEY_CIPHERTEXT_BYTES; 43 const WRAPPING_AAD_DOMAIN: &[u8] = b"radroots.myc.wrapped_data_key.v1\0"; 44 const CONTEXT_PURPOSE: &str = "radroots.myc.encrypted_identity"; 45 const CONTEXT_SUBJECT_TYPE: &str = "provider_identity"; 46 const CONTEXT_PAYLOAD_SCHEMA: &str = "radroots.myc.identity_secret.v1"; 47 48 /// Stable source-free encrypted-envelope failure classification. 49 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 50 pub enum MycEncryptedIdentityEnvelopeErrorKind { 51 InvalidBinding, 52 InvalidCredential, 53 InvalidProvisioningMaterial, 54 InvalidPath, 55 MissingEnvelope, 56 AlreadyExists, 57 InsecureParent, 58 InsecureArtifact, 59 UnsupportedEnvelopeVersion, 60 MalformedEnvelope, 61 WrongCredential, 62 IdentityMismatch, 63 Io, 64 UnsupportedPlatform, 65 } 66 67 impl MycEncryptedIdentityEnvelopeErrorKind { 68 /// Returns the stable machine-facing safe code. 69 #[must_use] 70 pub const fn code(self) -> &'static str { 71 match self { 72 Self::InvalidBinding => "provider_envelope_binding_invalid", 73 Self::InvalidCredential => "provider_envelope_credential_invalid", 74 Self::InvalidProvisioningMaterial => "provider_envelope_material_invalid", 75 Self::InvalidPath => "provider_envelope_path_invalid", 76 Self::MissingEnvelope => "provider_envelope_missing", 77 Self::AlreadyExists => "provider_envelope_already_exists", 78 Self::InsecureParent => "provider_envelope_parent_insecure", 79 Self::InsecureArtifact => "provider_envelope_artifact_insecure", 80 Self::UnsupportedEnvelopeVersion => "provider_envelope_version_unsupported", 81 Self::MalformedEnvelope => "provider_envelope_malformed", 82 Self::WrongCredential => "provider_envelope_credential_rejected", 83 Self::IdentityMismatch => "provider_envelope_identity_mismatch", 84 Self::Io => "provider_envelope_io_failed", 85 Self::UnsupportedPlatform => "provider_envelope_platform_unsupported", 86 } 87 } 88 89 const fn message(self) -> &'static str { 90 match self { 91 Self::InvalidBinding => "encrypted identity provider binding is invalid", 92 Self::InvalidCredential => "encrypted identity credential is invalid", 93 Self::InvalidProvisioningMaterial => { 94 "encrypted identity provisioning material is invalid" 95 } 96 Self::InvalidPath => "encrypted identity path is invalid", 97 Self::MissingEnvelope => "encrypted identity envelope is missing", 98 Self::AlreadyExists => "encrypted identity envelope already exists", 99 Self::InsecureParent => "encrypted identity parent is insecure", 100 Self::InsecureArtifact => "encrypted identity artifact is insecure", 101 Self::UnsupportedEnvelopeVersion => { 102 "encrypted identity envelope version is unsupported" 103 } 104 Self::MalformedEnvelope => "encrypted identity envelope is malformed", 105 Self::WrongCredential => "encrypted identity credential was rejected", 106 Self::IdentityMismatch => "encrypted identity does not match configuration", 107 Self::Io => "encrypted identity storage failed", 108 Self::UnsupportedPlatform => "encrypted identity storage is unsupported", 109 } 110 } 111 } 112 113 /// One source-free encrypted-envelope failure. 114 #[derive(Clone, Copy, PartialEq, Eq)] 115 pub struct MycEncryptedIdentityEnvelopeError { 116 kind: MycEncryptedIdentityEnvelopeErrorKind, 117 } 118 119 impl MycEncryptedIdentityEnvelopeError { 120 /// Returns the stable failure kind. 121 #[must_use] 122 pub const fn kind(self) -> MycEncryptedIdentityEnvelopeErrorKind { 123 self.kind 124 } 125 126 /// Returns the stable machine-facing safe code. 127 #[must_use] 128 pub const fn code(self) -> &'static str { 129 self.kind.code() 130 } 131 } 132 133 impl fmt::Debug for MycEncryptedIdentityEnvelopeError { 134 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 135 formatter 136 .debug_struct("MycEncryptedIdentityEnvelopeError") 137 .field("kind", &self.kind) 138 .finish() 139 } 140 } 141 142 impl fmt::Display for MycEncryptedIdentityEnvelopeError { 143 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 144 formatter.write_str(self.kind.message()) 145 } 146 } 147 148 impl Error for MycEncryptedIdentityEnvelopeError {} 149 150 const fn envelope_error( 151 kind: MycEncryptedIdentityEnvelopeErrorKind, 152 ) -> MycEncryptedIdentityEnvelopeError { 153 MycEncryptedIdentityEnvelopeError { kind } 154 } 155 156 /// Sealed zeroizing wrapping credential resolved only by the governed credential boundary. 157 pub struct MycWrappingCredential(Zeroizing<[u8; WRAPPING_CREDENTIAL_BYTES]>); 158 159 /// Non-forgeable proof that owns credential bytes admitted by the governed resolver. 160 pub(crate) struct MycCredentialResolutionProof { 161 credential: Zeroizing<[u8; WRAPPING_CREDENTIAL_BYTES]>, 162 } 163 164 impl fmt::Debug for MycCredentialResolutionProof { 165 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 166 formatter.write_str("MycCredentialResolutionProof([sealed])") 167 } 168 } 169 170 impl MycWrappingCredential { 171 pub(crate) fn from_resolution( 172 proof: MycCredentialResolutionProof, 173 ) -> Result<Self, MycEncryptedIdentityEnvelopeError> { 174 if proof.credential.iter().all(|byte| *byte == 0) { 175 return Err(envelope_error( 176 MycEncryptedIdentityEnvelopeErrorKind::InvalidCredential, 177 )); 178 } 179 Ok(Self(proof.credential)) 180 } 181 182 fn expose<T>(&self, use_credential: impl FnOnce(&[u8; 32]) -> T) -> T { 183 use_credential(&self.0) 184 } 185 186 fn matches(&self, other: &[u8; 32]) -> bool { 187 self.expose(|credential| credential == other) 188 } 189 } 190 191 impl fmt::Debug for MycWrappingCredential { 192 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 193 formatter.write_str("MycWrappingCredential([redacted])") 194 } 195 } 196 197 /// Explicit single-owner material for one offline create-new provisioning operation. 198 pub struct MycEncryptedIdentityProvisioningMaterial { 199 identity_secret: Zeroizing<[u8; IDENTITY_SECRET_BYTES]>, 200 data_key: Zeroizing<[u8; IDENTITY_SECRET_BYTES]>, 201 envelope_nonce: [u8; NONCE_BYTES], 202 wrapping_nonce: [u8; NONCE_BYTES], 203 } 204 205 impl MycEncryptedIdentityProvisioningMaterial { 206 /// Validates the identity secret and exact caller-supplied cryptographic material. 207 pub fn new( 208 identity_secret: [u8; IDENTITY_SECRET_BYTES], 209 data_key: [u8; IDENTITY_SECRET_BYTES], 210 envelope_nonce: [u8; NONCE_BYTES], 211 wrapping_nonce: [u8; NONCE_BYTES], 212 ) -> Result<Self, MycEncryptedIdentityEnvelopeError> { 213 let identity_secret = Zeroizing::new(identity_secret); 214 let data_key = Zeroizing::new(data_key); 215 if SecretKey::from_slice(&identity_secret[..]).is_err() 216 || data_key.iter().all(|byte| *byte == 0) 217 || envelope_nonce.iter().all(|byte| *byte == 0) 218 || wrapping_nonce.iter().all(|byte| *byte == 0) 219 { 220 return Err(envelope_error( 221 MycEncryptedIdentityEnvelopeErrorKind::InvalidProvisioningMaterial, 222 )); 223 } 224 Ok(Self { 225 identity_secret, 226 data_key, 227 envelope_nonce, 228 wrapping_nonce, 229 }) 230 } 231 } 232 233 impl fmt::Debug for MycEncryptedIdentityProvisioningMaterial { 234 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 235 formatter.write_str("MycEncryptedIdentityProvisioningMaterial([redacted])") 236 } 237 } 238 239 /// One verified zeroizing identity released only after envelope and public-key validation. 240 pub struct MycDecryptedIdentity { 241 secret: Zeroizing<[u8; IDENTITY_SECRET_BYTES]>, 242 public_identity: MycProviderPublicIdentity, 243 } 244 245 impl MycDecryptedIdentity { 246 /// Returns the independently verified configured public identity. 247 #[must_use] 248 pub fn public_identity(&self) -> &MycProviderPublicIdentity { 249 &self.public_identity 250 } 251 252 pub(crate) fn secret_bytes(&self) -> &[u8; IDENTITY_SECRET_BYTES] { 253 &self.secret 254 } 255 256 #[cfg(test)] 257 pub(crate) fn from_test_secret(secret: [u8; IDENTITY_SECRET_BYTES]) -> Self { 258 let secret_key = SecretKey::from_slice(&secret).expect("test secret must be valid"); 259 let public_identity = 260 MycProviderPublicIdentity::new(&Keys::new(secret_key).public_key().to_hex()) 261 .expect("test public identity must be valid"); 262 Self { 263 secret: Zeroizing::new(secret), 264 public_identity, 265 } 266 } 267 } 268 269 impl fmt::Debug for MycDecryptedIdentity { 270 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 271 formatter 272 .debug_struct("MycDecryptedIdentity") 273 .field("secret", &"[redacted]") 274 .field("secret_bytes", &self.secret.len()) 275 .field("public_identity", &"[redacted]") 276 .finish() 277 } 278 } 279 280 /// Provisions one new encrypted identity envelope without overwriting any entry. 281 /// 282 /// A wrapping credential can be obtained only through the separately governed 283 /// credential-resolution boundary. Ordinary service startup never calls this 284 /// offline provisioning operation. 285 pub fn provision_myc_encrypted_identity( 286 binding: &MycProviderBinding, 287 credential: &MycWrappingCredential, 288 material: MycEncryptedIdentityProvisioningMaterial, 289 ) -> Result<MycDecryptedIdentity, MycEncryptedIdentityEnvelopeError> { 290 ensure_supported_platform()?; 291 validate_encrypted_binding(binding)?; 292 validate_requested_path(envelope_path(binding)?)?; 293 let expected = binding.expected_identity(); 294 require_identity_match(&material.identity_secret, expected)?; 295 if credential.matches(&material.identity_secret) 296 || credential.matches(&material.data_key) 297 || material.identity_secret[..] == material.data_key[..] 298 { 299 return Err(invalid_material()); 300 } 301 302 let context = envelope_context(binding)?; 303 let reference = envelope_reference(binding)?; 304 let plaintext = SecretMaterial::from_slice(&material.identity_secret[..]) 305 .map_err(|_| invalid_material())?; 306 let data_key = 307 SecretMaterial::from_slice(&material.data_key[..]).map_err(|_| invalid_material())?; 308 let sealer = CredentialSealer::new(credential, material.wrapping_nonce); 309 let envelope = futures_executor::block_on(EncryptedEnvelope::seal( 310 &sealer, 311 SealRequest::new( 312 reference, 313 context.clone(), 314 &plaintext, 315 SealMaterial::new(data_key, Nonce::new(material.envelope_nonce)), 316 ), 317 )) 318 .map_err(|_| invalid_material())?; 319 let encoded = envelope 320 .encode() 321 .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::MalformedEnvelope))?; 322 let verified = futures_executor::block_on(open_decoded_envelope( 323 binding, credential, envelope, &context, 324 ))?; 325 persist_create_new(envelope_path(binding)?, &encoded)?; 326 Ok(verified) 327 } 328 329 /// Opens and verifies one existing encrypted identity envelope. 330 pub fn open_myc_encrypted_identity( 331 binding: &MycProviderBinding, 332 credential: &MycWrappingCredential, 333 ) -> Result<MycDecryptedIdentity, MycEncryptedIdentityEnvelopeError> { 334 ensure_supported_platform()?; 335 validate_encrypted_binding(binding)?; 336 validate_requested_path(envelope_path(binding)?)?; 337 let encoded = read_existing(envelope_path(binding)?)?; 338 require_wire_version(&encoded)?; 339 let envelope = EncryptedEnvelope::decode(&encoded) 340 .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::MalformedEnvelope))?; 341 if envelope.version() != ENVELOPE_VERSION { 342 return Err(envelope_error( 343 MycEncryptedIdentityEnvelopeErrorKind::UnsupportedEnvelopeVersion, 344 )); 345 } 346 let context = envelope_context(binding)?; 347 futures_executor::block_on(open_decoded_envelope( 348 binding, credential, envelope, &context, 349 )) 350 } 351 352 pub(crate) fn load_resolved_wrapping_credential( 353 path: &Path, 354 ) -> Result<MycWrappingCredential, MycEncryptedIdentityEnvelopeError> { 355 ensure_supported_platform()?; 356 validate_requested_path(path)?; 357 let encoded = Zeroizing::new(read_existing_exact(path, WRAPPING_CREDENTIAL_BYTES)?); 358 let mut credential = Zeroizing::new([0_u8; WRAPPING_CREDENTIAL_BYTES]); 359 credential.copy_from_slice(&encoded); 360 MycWrappingCredential::from_resolution(MycCredentialResolutionProof { credential }) 361 } 362 363 fn require_wire_version(encoded: &[u8]) -> Result<(), MycEncryptedIdentityEnvelopeError> { 364 if encoded.len() < 6 || &encoded[..4] != b"RRS1" { 365 return Err(envelope_error( 366 MycEncryptedIdentityEnvelopeErrorKind::MalformedEnvelope, 367 )); 368 } 369 let version = u16::from_be_bytes([encoded[4], encoded[5]]); 370 if version != ENVELOPE_VERSION { 371 return Err(envelope_error( 372 MycEncryptedIdentityEnvelopeErrorKind::UnsupportedEnvelopeVersion, 373 )); 374 } 375 Ok(()) 376 } 377 378 async fn open_decoded_envelope( 379 binding: &MycProviderBinding, 380 credential: &MycWrappingCredential, 381 envelope: EncryptedEnvelope, 382 expected_context: &EnvelopeContext, 383 ) -> Result<MycDecryptedIdentity, MycEncryptedIdentityEnvelopeError> { 384 if envelope.version() != ENVELOPE_VERSION { 385 return Err(envelope_error( 386 MycEncryptedIdentityEnvelopeErrorKind::UnsupportedEnvelopeVersion, 387 )); 388 } 389 let reference = envelope_reference(binding)?; 390 if !reference_matches(envelope.reference(), &reference) 391 || envelope.context() != Some(expected_context) 392 { 393 return Err(envelope_error( 394 MycEncryptedIdentityEnvelopeErrorKind::MalformedEnvelope, 395 )); 396 } 397 let opener = CredentialOpener::new(credential); 398 let plaintext = envelope 399 .open(&opener, expected_context) 400 .await 401 .map_err(|_| { 402 envelope_error(if opener.unwrap_succeeded() { 403 MycEncryptedIdentityEnvelopeErrorKind::MalformedEnvelope 404 } else { 405 MycEncryptedIdentityEnvelopeErrorKind::WrongCredential 406 }) 407 })?; 408 let mut secret = Zeroizing::new([0_u8; IDENTITY_SECRET_BYTES]); 409 let exact = plaintext.expose_secret(|bytes| { 410 if bytes.len() == IDENTITY_SECRET_BYTES { 411 secret.copy_from_slice(bytes); 412 true 413 } else { 414 false 415 } 416 }); 417 if !exact { 418 return Err(envelope_error( 419 MycEncryptedIdentityEnvelopeErrorKind::MalformedEnvelope, 420 )); 421 } 422 require_identity_match(&secret, binding.expected_identity())?; 423 Ok(MycDecryptedIdentity { 424 secret, 425 public_identity: binding.expected_identity().clone(), 426 }) 427 } 428 429 fn validate_encrypted_binding( 430 binding: &MycProviderBinding, 431 ) -> Result<(), MycEncryptedIdentityEnvelopeError> { 432 if binding.kind() != MycProviderKind::EncryptedFile 433 || binding.credential_reference().is_none() 434 || binding.encrypted_envelope_path().is_none() 435 { 436 return Err(envelope_error( 437 MycEncryptedIdentityEnvelopeErrorKind::InvalidBinding, 438 )); 439 } 440 Ok(()) 441 } 442 443 fn envelope_path(binding: &MycProviderBinding) -> Result<&Path, MycEncryptedIdentityEnvelopeError> { 444 binding 445 .encrypted_envelope_path() 446 .ok_or_else(|| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::InvalidBinding)) 447 } 448 449 fn envelope_reference( 450 binding: &MycProviderBinding, 451 ) -> Result<SecretRef, MycEncryptedIdentityEnvelopeError> { 452 let credential = binding 453 .credential_reference() 454 .ok_or_else(|| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::InvalidBinding))?; 455 let id = SecretId::parse(credential.as_str()) 456 .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::InvalidCredential))?; 457 let key_version = KeyVersion::new(1) 458 .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::InvalidCredential))?; 459 Ok(SecretRef::new(id, BackendKind::External, key_version)) 460 } 461 462 fn envelope_context( 463 binding: &MycProviderBinding, 464 ) -> Result<EnvelopeContext, MycEncryptedIdentityEnvelopeError> { 465 let subject = format!( 466 "{}:{}", 467 binding.role().as_str(), 468 binding.expected_identity().as_hex() 469 ); 470 Ok(EnvelopeContext::new( 471 EnvelopePurpose::parse(CONTEXT_PURPOSE).map_err(|_| invalid_binding())?, 472 EnvelopeSubject::parse(CONTEXT_SUBJECT_TYPE, subject).map_err(|_| invalid_binding())?, 473 PayloadSchemaId::parse(CONTEXT_PAYLOAD_SCHEMA).map_err(|_| invalid_binding())?, 474 )) 475 } 476 477 fn require_identity_match( 478 secret: &[u8; IDENTITY_SECRET_BYTES], 479 expected: &MycProviderPublicIdentity, 480 ) -> Result<(), MycEncryptedIdentityEnvelopeError> { 481 let secret_key = SecretKey::from_slice(secret).map_err(|_| invalid_material())?; 482 let actual = Keys::new(secret_key).public_key().to_hex(); 483 if actual != expected.as_hex() { 484 return Err(envelope_error( 485 MycEncryptedIdentityEnvelopeErrorKind::IdentityMismatch, 486 )); 487 } 488 Ok(()) 489 } 490 491 const fn invalid_binding() -> MycEncryptedIdentityEnvelopeError { 492 envelope_error(MycEncryptedIdentityEnvelopeErrorKind::InvalidBinding) 493 } 494 495 const fn invalid_material() -> MycEncryptedIdentityEnvelopeError { 496 envelope_error(MycEncryptedIdentityEnvelopeErrorKind::InvalidProvisioningMaterial) 497 } 498 499 fn reference_matches(actual: &SecretRef, expected: &SecretRef) -> bool { 500 actual.id().as_str() == expected.id().as_str() 501 && actual.backend() == expected.backend() 502 && actual.key_version() == expected.key_version() 503 } 504 505 struct CredentialSealer<'a> { 506 credential: &'a MycWrappingCredential, 507 nonce: Mutex<Option<[u8; NONCE_BYTES]>>, 508 } 509 510 impl<'a> CredentialSealer<'a> { 511 fn new(credential: &'a MycWrappingCredential, nonce: [u8; NONCE_BYTES]) -> Self { 512 Self { 513 credential, 514 nonce: Mutex::new(Some(nonce)), 515 } 516 } 517 } 518 519 impl KeyWrapping for CredentialSealer<'_> { 520 fn wrap<'a>( 521 &'a self, 522 request: WrapRequest<'a>, 523 ) -> BoxFuture<'a, Result<WrappedSecret, radroots_secrets::Error>> { 524 Box::pin(async move { 525 validate_external_reference(request.reference(), Operation::Wrap)?; 526 let nonce = self 527 .nonce 528 .lock() 529 .map_err(|_| backend_failure(Operation::Wrap))? 530 .take() 531 .ok_or_else(|| backend_failure(Operation::Wrap))?; 532 let aad = wrapping_aad(request.reference(), request.context()); 533 let ciphertext = self.credential.expose(|credential| { 534 request.plaintext().expose_secret(|data_key| { 535 XChaCha20Poly1305::new(Key::from_slice(credential)).encrypt( 536 XNonce::from_slice(&nonce), 537 Payload { 538 msg: data_key, 539 aad: &aad, 540 }, 541 ) 542 }) 543 }); 544 let ciphertext = ciphertext.map_err(|_| backend_failure(Operation::Wrap))?; 545 let mut encoded = Vec::with_capacity(WRAPPED_KEY_BYTES); 546 encoded.extend_from_slice(&WRAPPED_KEY_MAGIC); 547 encoded.push(WRAPPED_KEY_VERSION); 548 encoded.extend_from_slice(&nonce); 549 encoded.extend_from_slice(&ciphertext); 550 WrappedSecret::from_bytes(encoded) 551 }) 552 } 553 554 fn unwrap<'a>( 555 &'a self, 556 _request: UnwrapRequest<'a>, 557 ) -> BoxFuture<'a, Result<SecretMaterial, radroots_secrets::Error>> { 558 Box::pin(async { Err(backend_failure(Operation::Unwrap)) }) 559 } 560 } 561 562 struct CredentialOpener<'a> { 563 credential: &'a MycWrappingCredential, 564 unwrap_succeeded: AtomicBool, 565 } 566 567 impl<'a> CredentialOpener<'a> { 568 fn new(credential: &'a MycWrappingCredential) -> Self { 569 Self { 570 credential, 571 unwrap_succeeded: AtomicBool::new(false), 572 } 573 } 574 575 fn unwrap_succeeded(&self) -> bool { 576 self.unwrap_succeeded.load(Ordering::Acquire) 577 } 578 } 579 580 impl KeyWrapping for CredentialOpener<'_> { 581 fn wrap<'a>( 582 &'a self, 583 _request: WrapRequest<'a>, 584 ) -> BoxFuture<'a, Result<WrappedSecret, radroots_secrets::Error>> { 585 Box::pin(async { Err(backend_failure(Operation::Wrap)) }) 586 } 587 588 fn unwrap<'a>( 589 &'a self, 590 request: UnwrapRequest<'a>, 591 ) -> BoxFuture<'a, Result<SecretMaterial, radroots_secrets::Error>> { 592 Box::pin(async move { 593 validate_external_reference(request.reference(), Operation::Unwrap)?; 594 let encoded = request.wrapped().as_bytes(); 595 if encoded.len() != WRAPPED_KEY_BYTES 596 || encoded[..WRAPPED_KEY_MAGIC.len()] != WRAPPED_KEY_MAGIC 597 || encoded[WRAPPED_KEY_MAGIC.len()] != WRAPPED_KEY_VERSION 598 { 599 return Err(backend_failure(Operation::Unwrap)); 600 } 601 let nonce_start = WRAPPED_KEY_MAGIC.len() + 1; 602 let nonce_end = nonce_start + NONCE_BYTES; 603 let aad = wrapping_aad(request.reference(), request.context()); 604 let plaintext = self.credential.expose(|credential| { 605 XChaCha20Poly1305::new(Key::from_slice(credential)).decrypt( 606 XNonce::from_slice(&encoded[nonce_start..nonce_end]), 607 Payload { 608 msg: &encoded[nonce_end..], 609 aad: &aad, 610 }, 611 ) 612 }); 613 let plaintext = 614 Zeroizing::new(plaintext.map_err(|_| backend_failure(Operation::Unwrap))?); 615 let material = SecretMaterial::from_slice(&plaintext)?; 616 self.unwrap_succeeded.store(true, Ordering::Release); 617 Ok(material) 618 }) 619 } 620 } 621 622 fn wrapping_aad(reference: &SecretRef, context: &EnvelopeContext) -> Vec<u8> { 623 let id = reference.id().as_str().as_bytes(); 624 let mut aad = Vec::with_capacity(WRAPPING_AAD_DOMAIN.len() + 2 + id.len() + 4 + 32); 625 aad.extend_from_slice(WRAPPING_AAD_DOMAIN); 626 aad.extend_from_slice( 627 &u16::try_from(id.len()) 628 .unwrap_or_else(|_| unreachable!("validated secret reference fits u16")) 629 .to_be_bytes(), 630 ); 631 aad.extend_from_slice(id); 632 aad.extend_from_slice(&reference.key_version().get().to_be_bytes()); 633 aad.extend_from_slice(&context.authentication_digest()); 634 aad 635 } 636 637 fn validate_external_reference( 638 reference: &SecretRef, 639 operation: Operation, 640 ) -> Result<(), radroots_secrets::Error> { 641 if reference.backend() != BackendKind::External || reference.key_version().get() != 1 { 642 return Err(backend_failure(operation)); 643 } 644 Ok(()) 645 } 646 647 const fn backend_failure(operation: Operation) -> radroots_secrets::Error { 648 radroots_secrets::Error::BackendFailure { 649 backend: BackendKind::External, 650 operation, 651 } 652 } 653 654 #[cfg(any(target_os = "linux", target_os = "macos"))] 655 mod native { 656 use std::ffi::OsString; 657 use std::fs::File; 658 use std::io::{Read, Seek, SeekFrom, Write}; 659 use std::os::unix::ffi::OsStrExt; 660 use std::path::{Component, Path, PathBuf}; 661 662 use rustix::fs::{AtFlags, FileType, Mode, OFlags, fchmod, fstat, open, openat, unlinkat}; 663 use rustix::process::geteuid; 664 665 use super::{ 666 MYC_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES, MycEncryptedIdentityEnvelopeError, 667 MycEncryptedIdentityEnvelopeErrorKind, envelope_error, 668 }; 669 670 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 671 struct Identity { 672 device: u64, 673 inode: u64, 674 } 675 676 struct ArtifactPath { 677 parent_path: PathBuf, 678 name: OsString, 679 } 680 681 impl ArtifactPath { 682 fn parse(path: &Path) -> Result<Self, MycEncryptedIdentityEnvelopeError> { 683 if !path.is_absolute() 684 || path.as_os_str().as_bytes().len() > 4_096 685 || path.components().any(|component| { 686 !matches!(component, Component::RootDir | Component::Normal(_)) 687 }) 688 { 689 return Err(envelope_error( 690 MycEncryptedIdentityEnvelopeErrorKind::InvalidPath, 691 )); 692 } 693 let name = match path.components().next_back() { 694 Some(Component::Normal(name)) if !name.as_bytes().is_empty() => name.to_os_string(), 695 _ => { 696 return Err(envelope_error( 697 MycEncryptedIdentityEnvelopeErrorKind::InvalidPath, 698 )); 699 } 700 }; 701 let parent_path = path 702 .parent() 703 .filter(|parent| parent.is_absolute()) 704 .ok_or_else(|| { 705 envelope_error(MycEncryptedIdentityEnvelopeErrorKind::InvalidPath) 706 })?; 707 Ok(Self { 708 parent_path: parent_path.to_path_buf(), 709 name, 710 }) 711 } 712 } 713 714 pub(super) fn validate_requested_path( 715 path: &Path, 716 ) -> Result<(), MycEncryptedIdentityEnvelopeError> { 717 ArtifactPath::parse(path).map(|_| ()) 718 } 719 720 pub(super) fn persist_create_new( 721 path: &Path, 722 encoded: &[u8], 723 ) -> Result<(), MycEncryptedIdentityEnvelopeError> { 724 if encoded.is_empty() || encoded.len() > MYC_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES { 725 return Err(envelope_error( 726 MycEncryptedIdentityEnvelopeErrorKind::MalformedEnvelope, 727 )); 728 } 729 let path = ArtifactPath::parse(path)?; 730 let parent = open_parent(&path.parent_path, true)?; 731 let parent_identity = directory_identity(&parent, true)?; 732 let descriptor = openat( 733 &parent, 734 &path.name, 735 OFlags::WRONLY 736 | OFlags::CREATE 737 | OFlags::EXCL 738 | OFlags::NOFOLLOW 739 | OFlags::CLOEXEC 740 | OFlags::NONBLOCK, 741 Mode::RUSR | Mode::WUSR, 742 ) 743 .map_err(|source| { 744 envelope_error(if source == rustix::io::Errno::EXIST { 745 MycEncryptedIdentityEnvelopeErrorKind::AlreadyExists 746 } else { 747 MycEncryptedIdentityEnvelopeErrorKind::Io 748 }) 749 })?; 750 let mut file = File::from(descriptor); 751 let identity = owned_file_identity(&file)?; 752 let result = (|| { 753 fchmod(&file, Mode::RUSR | Mode::WUSR) 754 .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::Io))?; 755 file.write_all(encoded) 756 .and_then(|()| file.sync_all()) 757 .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::Io))?; 758 file_identity( 759 &file, 760 Some(encoded.len()), 761 MYC_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES, 762 )?; 763 validate_current_binding( 764 &path, 765 &parent, 766 parent_identity, 767 &file, 768 identity, 769 encoded.len(), 770 MYC_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES, 771 )?; 772 parent 773 .sync_all() 774 .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::Io))?; 775 validate_current_binding( 776 &path, 777 &parent, 778 parent_identity, 779 &file, 780 identity, 781 encoded.len(), 782 MYC_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES, 783 ) 784 })(); 785 if result.is_err() { 786 cleanup_owned(&parent, &path.name, identity); 787 } 788 result 789 } 790 791 pub(super) fn read_existing(path: &Path) -> Result<Vec<u8>, MycEncryptedIdentityEnvelopeError> { 792 read_existing_bounded(path, MYC_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES, None) 793 } 794 795 pub(super) fn read_existing_exact( 796 path: &Path, 797 expected_length: usize, 798 ) -> Result<Vec<u8>, MycEncryptedIdentityEnvelopeError> { 799 read_existing_bounded(path, expected_length, Some(expected_length)) 800 } 801 802 fn read_existing_bounded( 803 path: &Path, 804 maximum_length: usize, 805 expected_length: Option<usize>, 806 ) -> Result<Vec<u8>, MycEncryptedIdentityEnvelopeError> { 807 let path = ArtifactPath::parse(path)?; 808 let parent = open_parent(&path.parent_path, false)?; 809 let parent_identity = directory_identity(&parent, false)?; 810 let descriptor = openat( 811 &parent, 812 &path.name, 813 OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK, 814 Mode::empty(), 815 ) 816 .map_err(|source| { 817 envelope_error(if source == rustix::io::Errno::NOENT { 818 MycEncryptedIdentityEnvelopeErrorKind::MissingEnvelope 819 } else if source == rustix::io::Errno::LOOP { 820 MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact 821 } else { 822 MycEncryptedIdentityEnvelopeErrorKind::Io 823 }) 824 })?; 825 let mut file = File::from(descriptor); 826 let status = fstat(&file) 827 .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact))?; 828 let length = validate_file_status(&status, expected_length, maximum_length)?; 829 let identity = status_identity( 830 &status, 831 MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact, 832 )?; 833 file.seek(SeekFrom::Start(0)) 834 .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::Io))?; 835 let mut encoded = Vec::with_capacity(length); 836 std::io::Read::by_ref(&mut file) 837 .take(u64::try_from(length).unwrap_or(u64::MAX).saturating_add(1)) 838 .read_to_end(&mut encoded) 839 .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::Io))?; 840 if encoded.len() != length { 841 return Err(envelope_error( 842 MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact, 843 )); 844 } 845 validate_current_binding( 846 &path, 847 &parent, 848 parent_identity, 849 &file, 850 identity, 851 length, 852 maximum_length, 853 )?; 854 Ok(encoded) 855 } 856 857 fn open_parent(path: &Path, writable: bool) -> Result<File, MycEncryptedIdentityEnvelopeError> { 858 let mut components = path.components(); 859 if !matches!(components.next(), Some(Component::RootDir)) { 860 return Err(envelope_error( 861 MycEncryptedIdentityEnvelopeErrorKind::InvalidPath, 862 )); 863 } 864 let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC; 865 let mut parent = 866 File::from(open(Path::new("/"), flags, Mode::empty()).map_err(|_| { 867 envelope_error(MycEncryptedIdentityEnvelopeErrorKind::InsecureParent) 868 })?); 869 for component in components { 870 let Component::Normal(name) = component else { 871 return Err(envelope_error( 872 MycEncryptedIdentityEnvelopeErrorKind::InvalidPath, 873 )); 874 }; 875 parent = File::from(openat(&parent, name, flags, Mode::empty()).map_err(|_| { 876 envelope_error(MycEncryptedIdentityEnvelopeErrorKind::InsecureParent) 877 })?); 878 } 879 directory_identity(&parent, writable)?; 880 Ok(parent) 881 } 882 883 fn directory_identity( 884 directory: &File, 885 writable: bool, 886 ) -> Result<Identity, MycEncryptedIdentityEnvelopeError> { 887 let status = fstat(directory) 888 .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::InsecureParent))?; 889 let mode = native_mode(status.st_mode); 890 let allowed_mode = if writable { 891 mode & 0o777 == 0o700 892 } else { 893 matches!(mode & 0o777, 0o500 | 0o700) 894 }; 895 if !FileType::from_raw_mode(status.st_mode).is_dir() 896 || status.st_uid != geteuid().as_raw() 897 || !allowed_mode 898 { 899 return Err(envelope_error( 900 MycEncryptedIdentityEnvelopeErrorKind::InsecureParent, 901 )); 902 } 903 status_identity( 904 &status, 905 MycEncryptedIdentityEnvelopeErrorKind::InsecureParent, 906 ) 907 } 908 909 fn file_identity( 910 file: &File, 911 expected_length: Option<usize>, 912 maximum_length: usize, 913 ) -> Result<Identity, MycEncryptedIdentityEnvelopeError> { 914 let status = fstat(file) 915 .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact))?; 916 validate_file_status(&status, expected_length, maximum_length)?; 917 status_identity( 918 &status, 919 MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact, 920 ) 921 } 922 923 fn owned_file_identity(file: &File) -> Result<Identity, MycEncryptedIdentityEnvelopeError> { 924 let status = fstat(file) 925 .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact))?; 926 let mode = native_mode(status.st_mode) & 0o777; 927 let length = usize::try_from(status.st_size) 928 .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact))?; 929 if !FileType::from_raw_mode(status.st_mode).is_file() 930 || native_link_count(status.st_nlink) != 1 931 || status.st_uid != geteuid().as_raw() 932 || !matches!(mode, 0o400 | 0o600) 933 || length > MYC_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES 934 { 935 return Err(envelope_error( 936 MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact, 937 )); 938 } 939 status_identity( 940 &status, 941 MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact, 942 ) 943 } 944 945 fn validate_file_status( 946 status: &rustix::fs::Stat, 947 expected_length: Option<usize>, 948 maximum_length: usize, 949 ) -> Result<usize, MycEncryptedIdentityEnvelopeError> { 950 let mode = native_mode(status.st_mode) & 0o777; 951 let length = usize::try_from(status.st_size) 952 .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact))?; 953 if !FileType::from_raw_mode(status.st_mode).is_file() 954 || native_link_count(status.st_nlink) != 1 955 || status.st_uid != geteuid().as_raw() 956 || !matches!(mode, 0o400 | 0o600) 957 || length == 0 958 || length > maximum_length 959 || expected_length.is_some_and(|expected| expected != length) 960 { 961 return Err(envelope_error( 962 MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact, 963 )); 964 } 965 Ok(length) 966 } 967 968 fn validate_current_binding( 969 path: &ArtifactPath, 970 held_parent: &File, 971 expected_parent: Identity, 972 held_file: &File, 973 expected_file: Identity, 974 expected_length: usize, 975 maximum_length: usize, 976 ) -> Result<(), MycEncryptedIdentityEnvelopeError> { 977 let current_parent = open_parent(&path.parent_path, false)?; 978 if directory_identity(held_parent, false)? != expected_parent 979 || directory_identity(¤t_parent, false)? != expected_parent 980 { 981 return Err(envelope_error( 982 MycEncryptedIdentityEnvelopeErrorKind::InsecureParent, 983 )); 984 } 985 let current_file = File::from( 986 openat( 987 ¤t_parent, 988 &path.name, 989 OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK, 990 Mode::empty(), 991 ) 992 .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact))?, 993 ); 994 if file_identity(held_file, Some(expected_length), maximum_length)? != expected_file 995 || file_identity(¤t_file, Some(expected_length), maximum_length)? != expected_file 996 { 997 return Err(envelope_error( 998 MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact, 999 )); 1000 } 1001 Ok(()) 1002 } 1003 1004 fn cleanup_owned(parent: &File, name: &std::ffi::OsStr, expected: Identity) { 1005 let Ok(current) = openat( 1006 parent, 1007 name, 1008 OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK, 1009 Mode::empty(), 1010 ) else { 1011 return; 1012 }; 1013 let current = File::from(current); 1014 if owned_file_identity(¤t) == Ok(expected) 1015 && unlinkat(parent, name, AtFlags::empty()).is_ok() 1016 { 1017 let _ = parent.sync_all(); 1018 } 1019 } 1020 1021 fn status_identity( 1022 status: &rustix::fs::Stat, 1023 invalid_kind: MycEncryptedIdentityEnvelopeErrorKind, 1024 ) -> Result<Identity, MycEncryptedIdentityEnvelopeError> { 1025 Ok(Identity { 1026 device: native_device(status.st_dev).map_err(|_| envelope_error(invalid_kind))?, 1027 inode: status.st_ino, 1028 }) 1029 } 1030 1031 fn native_mode<T: Into<u32>>(raw: T) -> u32 { 1032 raw.into() 1033 } 1034 1035 fn native_link_count<T: Into<u64>>(raw: T) -> u64 { 1036 raw.into() 1037 } 1038 1039 fn native_device<T: TryInto<u64>>(raw: T) -> Result<u64, T::Error> { 1040 raw.try_into() 1041 } 1042 } 1043 1044 #[cfg(any(target_os = "linux", target_os = "macos"))] 1045 use native::{persist_create_new, read_existing, read_existing_exact, validate_requested_path}; 1046 1047 #[cfg(any(target_os = "linux", target_os = "macos"))] 1048 const fn ensure_supported_platform() -> Result<(), MycEncryptedIdentityEnvelopeError> { 1049 Ok(()) 1050 } 1051 1052 #[cfg(not(any(target_os = "linux", target_os = "macos")))] 1053 fn validate_requested_path(_path: &Path) -> Result<(), MycEncryptedIdentityEnvelopeError> { 1054 Err(envelope_error( 1055 MycEncryptedIdentityEnvelopeErrorKind::UnsupportedPlatform, 1056 )) 1057 } 1058 1059 #[cfg(not(any(target_os = "linux", target_os = "macos")))] 1060 const fn ensure_supported_platform() -> Result<(), MycEncryptedIdentityEnvelopeError> { 1061 Err(envelope_error( 1062 MycEncryptedIdentityEnvelopeErrorKind::UnsupportedPlatform, 1063 )) 1064 } 1065 1066 #[cfg(not(any(target_os = "linux", target_os = "macos")))] 1067 fn persist_create_new( 1068 _path: &Path, 1069 _encoded: &[u8], 1070 ) -> Result<(), MycEncryptedIdentityEnvelopeError> { 1071 Err(envelope_error( 1072 MycEncryptedIdentityEnvelopeErrorKind::UnsupportedPlatform, 1073 )) 1074 } 1075 1076 #[cfg(not(any(target_os = "linux", target_os = "macos")))] 1077 fn read_existing(_path: &Path) -> Result<Vec<u8>, MycEncryptedIdentityEnvelopeError> { 1078 Err(envelope_error( 1079 MycEncryptedIdentityEnvelopeErrorKind::UnsupportedPlatform, 1080 )) 1081 } 1082 1083 #[cfg(not(any(target_os = "linux", target_os = "macos")))] 1084 fn read_existing_exact( 1085 _path: &Path, 1086 _expected_length: usize, 1087 ) -> Result<Vec<u8>, MycEncryptedIdentityEnvelopeError> { 1088 Err(envelope_error( 1089 MycEncryptedIdentityEnvelopeErrorKind::UnsupportedPlatform, 1090 )) 1091 } 1092 1093 #[cfg(test)] 1094 mod tests { 1095 #[cfg(any(target_os = "linux", target_os = "macos"))] 1096 use std::fs; 1097 #[cfg(any(target_os = "linux", target_os = "macos"))] 1098 use std::os::unix::fs::{PermissionsExt, symlink}; 1099 1100 use sha2::{Digest, Sha256}; 1101 1102 #[cfg(any(target_os = "linux", target_os = "macos"))] 1103 use crate::{MycConfigProfile, MycProviderRole, parse_myc_config_v1}; 1104 1105 use super::*; 1106 1107 #[cfg(any(target_os = "linux", target_os = "macos"))] 1108 const CONFIG: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); 1109 1110 fn bytes(label: &str) -> [u8; 32] { 1111 Sha256::digest(label.as_bytes()).into() 1112 } 1113 1114 fn identity_secret() -> [u8; 32] { 1115 let mut candidate = bytes("radroots.myc.test-only.identity-secret.v1"); 1116 while SecretKey::from_slice(&candidate).is_err() { 1117 candidate = Sha256::digest(candidate).into(); 1118 } 1119 candidate 1120 } 1121 1122 #[cfg(any(target_os = "linux", target_os = "macos"))] 1123 fn expected_identity() -> String { 1124 Keys::new(SecretKey::from_slice(&identity_secret()).expect("test key")) 1125 .public_key() 1126 .to_hex() 1127 } 1128 1129 #[cfg(any(target_os = "linux", target_os = "macos"))] 1130 fn binding(path: &Path) -> MycProviderBinding { 1131 let source = CONFIG 1132 .replace( 1133 "/var/lib/radroots/services/myc/primary/secrets/transport.identity.ncrypt", 1134 path.to_str().expect("UTF-8 test path"), 1135 ) 1136 .replace( 1137 "4444444444444444444444444444444444444444444444444444444444444444", 1138 &expected_identity(), 1139 ); 1140 parse_myc_config_v1(source.as_bytes(), MycConfigProfile::Production) 1141 .expect("test config") 1142 .provider_contract() 1143 .binding(MycProviderRole::Transport) 1144 .expect("transport binding") 1145 .clone() 1146 } 1147 1148 #[cfg(any(target_os = "linux", target_os = "macos"))] 1149 fn credential(label: &str) -> MycWrappingCredential { 1150 MycWrappingCredential::from_resolution(MycCredentialResolutionProof { 1151 credential: Zeroizing::new(bytes(label)), 1152 }) 1153 .expect("test credential") 1154 } 1155 1156 fn material_for(identity: [u8; 32]) -> MycEncryptedIdentityProvisioningMaterial { 1157 MycEncryptedIdentityProvisioningMaterial::new( 1158 identity, 1159 bytes("radroots.myc.test-only.data-key.v1"), 1160 [7; 24], 1161 [9; 24], 1162 ) 1163 .expect("test material") 1164 } 1165 1166 fn material() -> MycEncryptedIdentityProvisioningMaterial { 1167 material_for(identity_secret()) 1168 } 1169 1170 #[cfg(any(target_os = "linux", target_os = "macos"))] 1171 fn different_identity_secret() -> [u8; 32] { 1172 let mut candidate = bytes("radroots.myc.test-only.different-identity-secret.v1"); 1173 while SecretKey::from_slice(&candidate).is_err() { 1174 candidate = Sha256::digest(candidate).into(); 1175 } 1176 candidate 1177 } 1178 1179 #[cfg(any(target_os = "linux", target_os = "macos"))] 1180 fn secure_directory() -> tempfile::TempDir { 1181 let directory = tempfile::tempdir().expect("temporary directory"); 1182 fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)) 1183 .expect("secure mode"); 1184 directory 1185 } 1186 1187 #[cfg(any(target_os = "linux", target_os = "macos"))] 1188 #[test] 1189 fn create_new_round_trip_binds_context_identity_and_permissions() { 1190 let directory = secure_directory(); 1191 let path = directory.path().join("transport.identity.ncrypt"); 1192 let binding = binding(&path); 1193 let credential = credential("radroots.myc.test-only.wrapping.v1"); 1194 let provisioned = 1195 provision_myc_encrypted_identity(&binding, &credential, material()).expect("provision"); 1196 assert_eq!(provisioned.public_identity().as_hex(), expected_identity()); 1197 assert_eq!( 1198 fs::metadata(&path).expect("metadata").permissions().mode() & 0o777, 1199 0o600 1200 ); 1201 let reopened = open_myc_encrypted_identity(&binding, &credential).expect("open"); 1202 assert_eq!(reopened.public_identity().as_hex(), expected_identity()); 1203 let names = fs::read_dir(directory.path()) 1204 .expect("inventory") 1205 .map(|entry| entry.expect("entry").file_name()) 1206 .collect::<Vec<_>>(); 1207 assert_eq!( 1208 names, 1209 vec![std::ffi::OsString::from("transport.identity.ncrypt")] 1210 ); 1211 } 1212 1213 #[cfg(any(target_os = "linux", target_os = "macos"))] 1214 #[test] 1215 fn collisions_wrong_credentials_and_identity_mismatch_fail_safely() { 1216 let directory = secure_directory(); 1217 let path = directory.path().join("transport.identity.ncrypt"); 1218 let binding = binding(&path); 1219 let wrong_credential = credential("radroots.myc.test-only.wrong-wrapping.v1"); 1220 let credential = credential("radroots.myc.test-only.wrapping.v1"); 1221 assert_eq!( 1222 provision_myc_encrypted_identity( 1223 &binding, 1224 &credential, 1225 material_for(different_identity_secret()), 1226 ) 1227 .expect_err("wrong identity") 1228 .kind(), 1229 MycEncryptedIdentityEnvelopeErrorKind::IdentityMismatch 1230 ); 1231 assert!(!path.exists()); 1232 provision_myc_encrypted_identity(&binding, &credential, material()).expect("provision"); 1233 let before = fs::read(&path).expect("before"); 1234 assert_eq!( 1235 provision_myc_encrypted_identity(&binding, &credential, material()) 1236 .expect_err("collision") 1237 .kind(), 1238 MycEncryptedIdentityEnvelopeErrorKind::AlreadyExists 1239 ); 1240 assert_eq!(fs::read(&path).expect("after"), before); 1241 assert_eq!( 1242 open_myc_encrypted_identity(&binding, &wrong_credential) 1243 .expect_err("wrong credential") 1244 .kind(), 1245 MycEncryptedIdentityEnvelopeErrorKind::WrongCredential 1246 ); 1247 1248 let wrong_expected = CONFIG.replace( 1249 "/var/lib/radroots/services/myc/primary/secrets/transport.identity.ncrypt", 1250 path.to_str().expect("UTF-8 test path"), 1251 ); 1252 let wrong_binding = 1253 parse_myc_config_v1(wrong_expected.as_bytes(), MycConfigProfile::Production) 1254 .expect("wrong expected config") 1255 .provider_contract() 1256 .binding(MycProviderRole::Transport) 1257 .expect("transport") 1258 .clone(); 1259 assert_eq!( 1260 open_myc_encrypted_identity(&wrong_binding, &credential) 1261 .expect_err("context mismatch") 1262 .kind(), 1263 MycEncryptedIdentityEnvelopeErrorKind::MalformedEnvelope 1264 ); 1265 } 1266 1267 #[cfg(any(target_os = "linux", target_os = "macos"))] 1268 #[test] 1269 fn malformed_oversized_and_insecure_artifacts_fail_before_secret_release() { 1270 let directory = secure_directory(); 1271 let path = directory.path().join("transport.identity.ncrypt"); 1272 let binding = binding(&path); 1273 let credential = credential("radroots.myc.test-only.wrapping.v1"); 1274 assert_eq!( 1275 open_myc_encrypted_identity(&binding, &credential) 1276 .expect_err("missing") 1277 .kind(), 1278 MycEncryptedIdentityEnvelopeErrorKind::MissingEnvelope 1279 ); 1280 provision_myc_encrypted_identity(&binding, &credential, material()).expect("provision"); 1281 let valid = fs::read(&path).expect("valid envelope"); 1282 let second_link = directory.path().join("second-link.ncrypt"); 1283 fs::hard_link(&path, &second_link).expect("hard link"); 1284 assert_eq!( 1285 open_myc_encrypted_identity(&binding, &credential) 1286 .expect_err("multiple links") 1287 .kind(), 1288 MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact 1289 ); 1290 fs::remove_file(&second_link).expect("remove hard link"); 1291 fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o500)) 1292 .expect("read-only parent mode"); 1293 open_myc_encrypted_identity(&binding, &credential).expect("0500 parent is owner-only"); 1294 fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)) 1295 .expect("restore parent mode"); 1296 fs::set_permissions(&path, fs::Permissions::from_mode(0o400)).expect("read-only mode"); 1297 open_myc_encrypted_identity(&binding, &credential).expect("0400 is owner-only"); 1298 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("writable mode"); 1299 let mut tampered = valid.clone(); 1300 *tampered.last_mut().expect("ciphertext byte") ^= 1; 1301 fs::write(&path, &tampered).expect("tamper ciphertext"); 1302 assert_eq!( 1303 open_myc_encrypted_identity(&binding, &credential) 1304 .expect_err("tampered ciphertext") 1305 .kind(), 1306 MycEncryptedIdentityEnvelopeErrorKind::MalformedEnvelope 1307 ); 1308 let mut unsupported = valid; 1309 unsupported[4..6].copy_from_slice(&1_u16.to_be_bytes()); 1310 fs::write(&path, &unsupported).expect("unsupported version"); 1311 assert_eq!( 1312 open_myc_encrypted_identity(&binding, &credential) 1313 .expect_err("unsupported version") 1314 .kind(), 1315 MycEncryptedIdentityEnvelopeErrorKind::UnsupportedEnvelopeVersion 1316 ); 1317 fs::remove_file(&path).expect("remove version vector"); 1318 fs::write(&path, b"not-an-envelope").expect("malformed"); 1319 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("mode"); 1320 assert_eq!( 1321 open_myc_encrypted_identity(&binding, &credential) 1322 .expect_err("malformed") 1323 .kind(), 1324 MycEncryptedIdentityEnvelopeErrorKind::MalformedEnvelope 1325 ); 1326 fs::remove_file(&path).expect("remove malformed"); 1327 fs::write( 1328 &path, 1329 vec![0_u8; MYC_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES + 1], 1330 ) 1331 .expect("oversized"); 1332 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("mode"); 1333 assert_eq!( 1334 open_myc_encrypted_identity(&binding, &credential) 1335 .expect_err("oversized") 1336 .kind(), 1337 MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact 1338 ); 1339 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("insecure mode"); 1340 assert_eq!( 1341 open_myc_encrypted_identity(&binding, &credential) 1342 .expect_err("permissions") 1343 .kind(), 1344 MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact 1345 ); 1346 } 1347 1348 #[cfg(any(target_os = "linux", target_os = "macos"))] 1349 #[test] 1350 fn symlink_and_insecure_parent_are_rejected_without_mutation() { 1351 let directory = secure_directory(); 1352 let target = directory.path().join("target"); 1353 fs::write(&target, b"preserve").expect("target"); 1354 fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).expect("target mode"); 1355 let path = directory.path().join("transport.identity.ncrypt"); 1356 symlink(&target, &path).expect("symlink"); 1357 let provider_binding = binding(&path); 1358 let credential = credential("radroots.myc.test-only.wrapping.v1"); 1359 assert_eq!( 1360 open_myc_encrypted_identity(&provider_binding, &credential) 1361 .expect_err("symlink") 1362 .kind(), 1363 MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact 1364 ); 1365 assert_eq!(fs::read(&target).expect("preserved"), b"preserve"); 1366 fs::remove_file(&path).expect("remove symlink"); 1367 fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o755)) 1368 .expect("insecure parent"); 1369 assert_eq!( 1370 provision_myc_encrypted_identity(&provider_binding, &credential, material()) 1371 .expect_err("insecure parent") 1372 .kind(), 1373 MycEncryptedIdentityEnvelopeErrorKind::InsecureParent 1374 ); 1375 assert!(!path.exists()); 1376 1377 fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)) 1378 .expect("restore parent mode"); 1379 let real_parent = directory.path().join("real-parent"); 1380 fs::create_dir(&real_parent).expect("real parent"); 1381 fs::set_permissions(&real_parent, fs::Permissions::from_mode(0o700)) 1382 .expect("real parent mode"); 1383 let linked_parent = directory.path().join("linked-parent"); 1384 symlink(&real_parent, &linked_parent).expect("parent symlink"); 1385 let linked_path = linked_parent.join("transport.identity.ncrypt"); 1386 let linked_binding = binding(&linked_path); 1387 assert_eq!( 1388 provision_myc_encrypted_identity(&linked_binding, &credential, material()) 1389 .expect_err("symlinked ancestor") 1390 .kind(), 1391 MycEncryptedIdentityEnvelopeErrorKind::InsecureParent 1392 ); 1393 assert!(!real_parent.join("transport.identity.ncrypt").exists()); 1394 } 1395 1396 #[test] 1397 fn protected_models_and_errors_are_redacted_and_source_free() { 1398 let proof = MycCredentialResolutionProof { 1399 credential: Zeroizing::new(bytes("radroots.myc.test-only.wrapping.v1")), 1400 }; 1401 assert_eq!( 1402 format!("{proof:?}"), 1403 "MycCredentialResolutionProof([sealed])" 1404 ); 1405 let credential = MycWrappingCredential::from_resolution(proof).expect("test credential"); 1406 let material = material(); 1407 assert_eq!( 1408 format!("{credential:?}"), 1409 "MycWrappingCredential([redacted])" 1410 ); 1411 assert_eq!( 1412 format!("{material:?}"), 1413 "MycEncryptedIdentityProvisioningMaterial([redacted])" 1414 ); 1415 for kind in [ 1416 MycEncryptedIdentityEnvelopeErrorKind::InvalidBinding, 1417 MycEncryptedIdentityEnvelopeErrorKind::InvalidCredential, 1418 MycEncryptedIdentityEnvelopeErrorKind::InvalidProvisioningMaterial, 1419 MycEncryptedIdentityEnvelopeErrorKind::InvalidPath, 1420 MycEncryptedIdentityEnvelopeErrorKind::MissingEnvelope, 1421 MycEncryptedIdentityEnvelopeErrorKind::AlreadyExists, 1422 MycEncryptedIdentityEnvelopeErrorKind::InsecureParent, 1423 MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact, 1424 MycEncryptedIdentityEnvelopeErrorKind::UnsupportedEnvelopeVersion, 1425 MycEncryptedIdentityEnvelopeErrorKind::MalformedEnvelope, 1426 MycEncryptedIdentityEnvelopeErrorKind::WrongCredential, 1427 MycEncryptedIdentityEnvelopeErrorKind::IdentityMismatch, 1428 MycEncryptedIdentityEnvelopeErrorKind::Io, 1429 MycEncryptedIdentityEnvelopeErrorKind::UnsupportedPlatform, 1430 ] { 1431 let error = envelope_error(kind); 1432 assert!(!error.code().is_empty()); 1433 assert!(!error.to_string().contains("test-only")); 1434 assert!(error.source().is_none()); 1435 } 1436 assert_eq!( 1437 MycWrappingCredential::from_resolution(MycCredentialResolutionProof { 1438 credential: Zeroizing::new([0; 32]), 1439 }) 1440 .expect_err("zero credential") 1441 .kind(), 1442 MycEncryptedIdentityEnvelopeErrorKind::InvalidCredential 1443 ); 1444 assert!( 1445 MycEncryptedIdentityProvisioningMaterial::new([0; 32], [1; 32], [1; 24], [2; 24]) 1446 .is_err() 1447 ); 1448 } 1449 }