rhi

Coordinated trade for connected markets
git clone https://radroots.dev/git/rhi.git
Log | Files | Refs | README | LICENSE

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(&current_parent, false)? != expected_parent
   1143         {
   1144             return Err(envelope_error(
   1145                 RhiEncryptedIdentityEnvelopeErrorKind::InsecureParent,
   1146             ));
   1147         }
   1148         let current_file = File::from(
   1149             openat(
   1150                 &current_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(&current_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(&current) == 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 }