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