myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

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(&current_parent, false)? != expected_parent
    980         {
    981             return Err(envelope_error(
    982                 MycEncryptedIdentityEnvelopeErrorKind::InsecureParent,
    983             ));
    984         }
    985         let current_file = File::from(
    986             openat(
    987                 &current_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(&current_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(&current) == 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 }