rhi

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

identity_credential.rs (19463B)


      1 //! Canonical wrapping-credential artifact resolution.
      2 
      3 use core::fmt;
      4 use std::error::Error;
      5 
      6 use radroots_runtime_paths::{ServiceCredentialArtifactName, service_credential_artifact_path};
      7 
      8 use crate::{
      9     RhiBootstrapProfileV1, RhiEncryptedIdentityEnvelopeErrorKind, RhiIdentityEnvelopeBinding,
     10     RhiIdentityProviderKind, RhiRuntimeContext, RhiWrappingCredential,
     11     identity_envelope::load_resolved_wrapping_credential,
     12 };
     13 
     14 /// Exact fixed wrapping-credential artifact length.
     15 pub const RHI_WRAPPING_CREDENTIAL_ARTIFACT_BYTES: usize = 32;
     16 /// Exact Rhi wrapping-credential resolution contract version.
     17 pub const RHI_WRAPPING_CREDENTIAL_CONTRACT_VERSION: u32 = 1;
     18 
     19 /// Stable source-free credential-resolution failure classification.
     20 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
     21 pub enum RhiCredentialResolutionErrorKind {
     22     InvalidBinding,
     23     UnsupportedProfile,
     24     InvalidReference,
     25     MissingCredential,
     26     InsecureSecretsRoot,
     27     InsecureCredential,
     28     InvalidCredential,
     29     Io,
     30     UnsupportedPlatform,
     31 }
     32 
     33 impl RhiCredentialResolutionErrorKind {
     34     /// Returns the stable machine-facing safe code.
     35     #[must_use]
     36     pub const fn code(self) -> &'static str {
     37         match self {
     38             Self::InvalidBinding => "provider_credential_binding_invalid",
     39             Self::UnsupportedProfile => "provider_credential_profile_unsupported",
     40             Self::InvalidReference => "provider_credential_reference_invalid",
     41             Self::MissingCredential => "provider_credential_missing",
     42             Self::InsecureSecretsRoot => "provider_credential_root_insecure",
     43             Self::InsecureCredential => "provider_credential_artifact_insecure",
     44             Self::InvalidCredential => "provider_credential_material_invalid",
     45             Self::Io => "provider_credential_io_failed",
     46             Self::UnsupportedPlatform => "provider_credential_platform_unsupported",
     47         }
     48     }
     49 
     50     const fn message(self) -> &'static str {
     51         match self {
     52             Self::InvalidBinding => "provider credential binding is invalid",
     53             Self::UnsupportedProfile => "provider credential profile is unsupported",
     54             Self::InvalidReference => "provider credential reference is invalid",
     55             Self::MissingCredential => "provider credential is missing",
     56             Self::InsecureSecretsRoot => "provider credential root is insecure",
     57             Self::InsecureCredential => "provider credential artifact is insecure",
     58             Self::InvalidCredential => "provider credential material is invalid",
     59             Self::Io => "provider credential storage failed",
     60             Self::UnsupportedPlatform => "provider credential storage is unsupported",
     61         }
     62     }
     63 }
     64 
     65 /// One source-free wrapping-credential resolution failure.
     66 #[derive(Clone, Copy, PartialEq, Eq)]
     67 pub struct RhiCredentialResolutionError {
     68     kind: RhiCredentialResolutionErrorKind,
     69 }
     70 
     71 impl RhiCredentialResolutionError {
     72     /// Returns the stable failure kind.
     73     #[must_use]
     74     pub const fn kind(self) -> RhiCredentialResolutionErrorKind {
     75         self.kind
     76     }
     77 
     78     /// Returns the stable machine-facing safe code.
     79     #[must_use]
     80     pub const fn code(self) -> &'static str {
     81         self.kind.code()
     82     }
     83 }
     84 
     85 impl fmt::Debug for RhiCredentialResolutionError {
     86     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     87         formatter
     88             .debug_struct("RhiCredentialResolutionError")
     89             .field("kind", &self.kind)
     90             .finish()
     91     }
     92 }
     93 
     94 impl fmt::Display for RhiCredentialResolutionError {
     95     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     96         formatter.write_str(self.kind.message())
     97     }
     98 }
     99 
    100 impl Error for RhiCredentialResolutionError {}
    101 
    102 const fn resolution_error(kind: RhiCredentialResolutionErrorKind) -> RhiCredentialResolutionError {
    103     RhiCredentialResolutionError { kind }
    104 }
    105 
    106 /// Resolves one existing wrapping credential from the canonical instance secrets root.
    107 ///
    108 /// The caller supplies no path or credential bytes. Production deployment and
    109 /// repo-local offline tooling provision the fixed artifact externally; this
    110 /// operation is read-only and never creates a credential or parent directory.
    111 pub fn resolve_rhi_wrapping_credential(
    112     runtime: &RhiRuntimeContext,
    113     binding: &RhiIdentityEnvelopeBinding,
    114 ) -> Result<RhiWrappingCredential, RhiCredentialResolutionError> {
    115     if !matches!(
    116         runtime.profile(),
    117         RhiBootstrapProfileV1::ServiceHost | RhiBootstrapProfileV1::RepoLocal
    118     ) {
    119         return Err(resolution_error(
    120             RhiCredentialResolutionErrorKind::UnsupportedProfile,
    121         ));
    122     }
    123     if binding.kind() != RhiIdentityProviderKind::EncryptedFile {
    124         return Err(resolution_error(
    125             RhiCredentialResolutionErrorKind::InvalidBinding,
    126         ));
    127     }
    128     if !binding.matches_runtime(runtime) {
    129         return Err(resolution_error(
    130             RhiCredentialResolutionErrorKind::InvalidBinding,
    131         ));
    132     }
    133     let reference = binding
    134         .credential_reference()
    135         .ok_or_else(|| resolution_error(RhiCredentialResolutionErrorKind::InvalidBinding))?;
    136     let name = ServiceCredentialArtifactName::new(reference.as_str())
    137         .map_err(|_| resolution_error(RhiCredentialResolutionErrorKind::InvalidReference))?;
    138     let path = service_credential_artifact_path(runtime.context().paths(), &name);
    139     if binding.encrypted_envelope_path() == Some(path.as_path()) {
    140         return Err(resolution_error(
    141             RhiCredentialResolutionErrorKind::InvalidBinding,
    142         ));
    143     }
    144     load_resolved_wrapping_credential(&path).map_err(|error| {
    145         let kind = match error.kind() {
    146             RhiEncryptedIdentityEnvelopeErrorKind::InvalidPath
    147             | RhiEncryptedIdentityEnvelopeErrorKind::InvalidBinding => {
    148                 RhiCredentialResolutionErrorKind::InvalidBinding
    149             }
    150             RhiEncryptedIdentityEnvelopeErrorKind::MissingEnvelope => {
    151                 RhiCredentialResolutionErrorKind::MissingCredential
    152             }
    153             RhiEncryptedIdentityEnvelopeErrorKind::InsecureParent => {
    154                 RhiCredentialResolutionErrorKind::InsecureSecretsRoot
    155             }
    156             RhiEncryptedIdentityEnvelopeErrorKind::InsecureArtifact => {
    157                 RhiCredentialResolutionErrorKind::InsecureCredential
    158             }
    159             RhiEncryptedIdentityEnvelopeErrorKind::InvalidCredential => {
    160                 RhiCredentialResolutionErrorKind::InvalidCredential
    161             }
    162             RhiEncryptedIdentityEnvelopeErrorKind::UnsupportedPlatform => {
    163                 RhiCredentialResolutionErrorKind::UnsupportedPlatform
    164             }
    165             RhiEncryptedIdentityEnvelopeErrorKind::InvalidProvisioningMaterial
    166             | RhiEncryptedIdentityEnvelopeErrorKind::AlreadyExists
    167             | RhiEncryptedIdentityEnvelopeErrorKind::UnsupportedEnvelopeVersion
    168             | RhiEncryptedIdentityEnvelopeErrorKind::MalformedEnvelope
    169             | RhiEncryptedIdentityEnvelopeErrorKind::WrongCredential
    170             | RhiEncryptedIdentityEnvelopeErrorKind::IdentityMismatch
    171             | RhiEncryptedIdentityEnvelopeErrorKind::Io => RhiCredentialResolutionErrorKind::Io,
    172         };
    173         resolution_error(kind)
    174     })
    175 }
    176 
    177 #[cfg(test)]
    178 mod tests {
    179     #[cfg(any(target_os = "linux", target_os = "macos"))]
    180     use std::fs;
    181     #[cfg(any(target_os = "linux", target_os = "macos"))]
    182     use std::os::unix::fs::{PermissionsExt, symlink};
    183     use std::path::{Path, PathBuf};
    184 
    185     use nostr::{Keys, SecretKey};
    186     use sha2::{Digest, Sha256};
    187 
    188     use radroots_storage::event::SourceGeneration;
    189 
    190     use crate::{
    191         RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, RhiConfigProfile,
    192         RhiStateMetadata, parse_rhi_cli_v1_from, parse_rhi_config_v1, resolve_rhi_runtime_context,
    193     };
    194 
    195     use super::*;
    196 
    197     const CONFIG: &str = include_str!("../contracts/services_hardening/config.v1.example.toml");
    198 
    199     fn bytes(label: &str) -> [u8; 32] {
    200         Sha256::digest(label.as_bytes()).into()
    201     }
    202 
    203     fn runtime(root: &Path, profile: &str, instance: &str) -> RhiRuntimeContext {
    204         let root = root.to_str().expect("UTF-8 test root");
    205         let arguments = if profile == "repo-local" {
    206             vec![
    207                 "rhi",
    208                 "--profile",
    209                 profile,
    210                 "--instance",
    211                 instance,
    212                 "--repo-local-root",
    213                 root,
    214                 "run",
    215             ]
    216         } else {
    217             vec!["rhi", "--profile", profile, "--instance", instance, "run"]
    218         };
    219         let invocation = parse_rhi_cli_v1_from(arguments).expect("test invocation");
    220         let environment = if profile == "interactive" {
    221             RadrootsHostEnvironment {
    222                 home_dir: Some(PathBuf::from(root)),
    223                 xdg_config_home: Some(PathBuf::from(root).join("config")),
    224                 xdg_data_home: Some(PathBuf::from(root).join("data")),
    225                 xdg_state_home: Some(PathBuf::from(root).join("state")),
    226                 xdg_cache_home: Some(PathBuf::from(root).join("cache")),
    227                 xdg_runtime_dir: Some(PathBuf::from(root).join("run")),
    228                 ..RadrootsHostEnvironment::default()
    229             }
    230         } else {
    231             RadrootsHostEnvironment::default()
    232         };
    233         resolve_rhi_runtime_context(
    234             &RadrootsPathResolver::new(RadrootsPlatform::Linux, environment),
    235             &invocation,
    236         )
    237         .expect("runtime context")
    238     }
    239 
    240     fn binding(runtime: &RhiRuntimeContext, envelope_path: &Path) -> RhiIdentityEnvelopeBinding {
    241         let mut identity = bytes("radroots.rhi.credential-test.identity.v1");
    242         while SecretKey::from_slice(&identity).is_err() {
    243             identity = Sha256::digest(identity).into();
    244         }
    245         let public_key = Keys::new(SecretKey::from_slice(&identity).expect("identity"))
    246             .public_key()
    247             .to_hex();
    248         let source = CONFIG
    249             .replace(
    250                 "/var/lib/radroots/services/rhi/default/secrets/service.identity.ncrypt",
    251                 envelope_path.to_str().expect("UTF-8 envelope path"),
    252             )
    253             .replace(&"2".repeat(64), &public_key);
    254         let configuration = parse_rhi_config_v1(source.as_bytes(), RhiConfigProfile::RepoLocal)
    255             .expect("configuration");
    256         let metadata = RhiStateMetadata::new(
    257             runtime,
    258             &configuration,
    259             SourceGeneration::new([0x6b; 32]).expect("source generation"),
    260             1,
    261         )
    262         .expect("state metadata");
    263         RhiIdentityEnvelopeBinding::from_configuration(&configuration, &metadata)
    264             .expect("identity binding")
    265     }
    266 
    267     #[cfg(any(target_os = "linux", target_os = "macos"))]
    268     fn prepare_credential(runtime: &RhiRuntimeContext, name: &str, contents: &[u8]) -> PathBuf {
    269         let root = runtime.context().paths().secrets();
    270         fs::create_dir_all(root).expect("secrets root");
    271         fs::set_permissions(root, fs::Permissions::from_mode(0o700)).expect("secrets mode");
    272         let path = root.join(name);
    273         fs::write(&path, contents).expect("credential artifact");
    274         fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("credential mode");
    275         path
    276     }
    277 
    278     #[cfg(any(target_os = "linux", target_os = "macos"))]
    279     #[test]
    280     fn canonical_existing_artifact_resolves_without_path_or_value_exposure() {
    281         let directory = tempfile::tempdir().expect("test root");
    282         let runtime = runtime(directory.path(), "repo-local", "primary");
    283         let envelope_parent = directory.path().join("envelopes");
    284         fs::create_dir(&envelope_parent).expect("envelope parent");
    285         fs::set_permissions(&envelope_parent, fs::Permissions::from_mode(0o700))
    286             .expect("envelope parent mode");
    287         let binding = binding(&runtime, &envelope_parent.join("service.identity.ncrypt"));
    288         let credential_bytes = bytes("radroots.rhi.credential-test.wrapping.v1");
    289         let path = prepare_credential(
    290             &runtime,
    291             binding
    292                 .credential_reference()
    293                 .expect("credential reference")
    294                 .as_str(),
    295             &credential_bytes,
    296         );
    297 
    298         let credential =
    299             resolve_rhi_wrapping_credential(&runtime, &binding).expect("credential resolution");
    300         assert_eq!(
    301             format!("{credential:?}"),
    302             "RhiWrappingCredential([redacted])"
    303         );
    304         assert_eq!(
    305             path,
    306             runtime
    307                 .context()
    308                 .paths()
    309                 .secrets()
    310                 .join("service_wrapping_key")
    311         );
    312         assert_eq!(
    313             fs::read(&path).expect("credential unchanged"),
    314             credential_bytes
    315         );
    316         assert_eq!(
    317             fs::metadata(&path)
    318                 .expect("credential metadata")
    319                 .permissions()
    320                 .mode()
    321                 & 0o777,
    322             0o600
    323         );
    324         fs::set_permissions(&path, fs::Permissions::from_mode(0o400))
    325             .expect("read-only credential mode");
    326         fs::set_permissions(
    327             runtime.context().paths().secrets(),
    328             fs::Permissions::from_mode(0o500),
    329         )
    330         .expect("read-only secrets root mode");
    331         resolve_rhi_wrapping_credential(&runtime, &binding)
    332             .expect("owner-read-only artifact and secrets root");
    333     }
    334 
    335     #[cfg(any(target_os = "linux", target_os = "macos"))]
    336     #[test]
    337     fn binding_cannot_be_reused_for_another_instance() {
    338         let directory = tempfile::tempdir().expect("test root");
    339         let primary = runtime(directory.path(), "repo-local", "primary");
    340         let secondary = runtime(directory.path(), "repo-local", "secondary");
    341         let binding = binding(&primary, &directory.path().join("service.identity.ncrypt"));
    342         prepare_credential(
    343             &secondary,
    344             "service_wrapping_key",
    345             &bytes("radroots.rhi.credential-test.secondary.v1"),
    346         );
    347 
    348         assert_eq!(
    349             resolve_rhi_wrapping_credential(&secondary, &binding)
    350                 .expect_err("binding is tied to the primary runtime")
    351                 .kind(),
    352             RhiCredentialResolutionErrorKind::InvalidBinding
    353         );
    354     }
    355 
    356     #[cfg(any(target_os = "linux", target_os = "macos"))]
    357     #[test]
    358     fn missing_adjacent_short_long_zero_and_insecure_artifacts_fail_closed() {
    359         let directory = tempfile::tempdir().expect("test root");
    360         let runtime = runtime(directory.path(), "repo-local", "primary");
    361         let envelope_parent = directory.path().join("envelopes");
    362         fs::create_dir(&envelope_parent).expect("envelope parent");
    363         fs::set_permissions(&envelope_parent, fs::Permissions::from_mode(0o700))
    364             .expect("envelope parent mode");
    365         let envelope_path = envelope_parent.join("service.identity.ncrypt");
    366         let binding = binding(&runtime, &envelope_path);
    367         let adjacent = envelope_parent.join("service.identity.key");
    368         fs::write(&adjacent, bytes("adjacent key")).expect("adjacent file");
    369         fs::create_dir_all(runtime.context().paths().secrets()).expect("secrets root");
    370         fs::set_permissions(
    371             runtime.context().paths().secrets(),
    372             fs::Permissions::from_mode(0o700),
    373         )
    374         .expect("secrets mode");
    375         assert_eq!(
    376             resolve_rhi_wrapping_credential(&runtime, &binding)
    377                 .expect_err("canonical credential is missing")
    378                 .kind(),
    379             RhiCredentialResolutionErrorKind::MissingCredential
    380         );
    381 
    382         let reference = binding.credential_reference().expect("reference").as_str();
    383         let path = prepare_credential(&runtime, reference, &[1; 31]);
    384         assert_eq!(
    385             resolve_rhi_wrapping_credential(&runtime, &binding)
    386                 .expect_err("short")
    387                 .kind(),
    388             RhiCredentialResolutionErrorKind::InsecureCredential
    389         );
    390         fs::write(&path, [1; 33]).expect("long");
    391         assert_eq!(
    392             resolve_rhi_wrapping_credential(&runtime, &binding)
    393                 .expect_err("long")
    394                 .kind(),
    395             RhiCredentialResolutionErrorKind::InsecureCredential
    396         );
    397         fs::write(&path, [0; 32]).expect("zero");
    398         assert_eq!(
    399             resolve_rhi_wrapping_credential(&runtime, &binding)
    400                 .expect_err("zero")
    401                 .kind(),
    402             RhiCredentialResolutionErrorKind::InvalidCredential
    403         );
    404         fs::write(&path, [1; 32]).expect("valid length");
    405         fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("insecure mode");
    406         assert_eq!(
    407             resolve_rhi_wrapping_credential(&runtime, &binding)
    408                 .expect_err("mode")
    409                 .kind(),
    410             RhiCredentialResolutionErrorKind::InsecureCredential
    411         );
    412         fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("restore mode");
    413         let second_link = runtime.context().paths().secrets().join("second-link");
    414         fs::hard_link(&path, &second_link).expect("hard link");
    415         assert_eq!(
    416             resolve_rhi_wrapping_credential(&runtime, &binding)
    417                 .expect_err("hard link")
    418                 .kind(),
    419             RhiCredentialResolutionErrorKind::InsecureCredential
    420         );
    421         fs::remove_file(&second_link).expect("remove hard link");
    422         fs::remove_file(&path).expect("remove credential");
    423         symlink(&adjacent, &path).expect("credential symlink");
    424         assert_eq!(
    425             resolve_rhi_wrapping_credential(&runtime, &binding)
    426                 .expect_err("symlink")
    427                 .kind(),
    428             RhiCredentialResolutionErrorKind::InsecureCredential
    429         );
    430     }
    431 
    432     #[test]
    433     fn unsupported_interactive_profile_and_errors_are_source_free() {
    434         let directory = tempfile::tempdir().expect("test root");
    435         let bound_runtime = runtime(directory.path(), "repo-local", "primary");
    436         let runtime = runtime(directory.path(), "interactive", "primary");
    437         let binding = binding(
    438             &bound_runtime,
    439             &directory.path().join("service.identity.ncrypt"),
    440         );
    441         let error =
    442             resolve_rhi_wrapping_credential(&runtime, &binding).expect_err("interactive profile");
    443         assert_eq!(
    444             error.kind(),
    445             RhiCredentialResolutionErrorKind::UnsupportedProfile
    446         );
    447         assert!(Error::source(&error).is_none());
    448         let rendered = format!("{error} {error:?}");
    449         assert!(!rendered.contains(directory.path().to_string_lossy().as_ref()));
    450         for kind in [
    451             RhiCredentialResolutionErrorKind::InvalidBinding,
    452             RhiCredentialResolutionErrorKind::UnsupportedProfile,
    453             RhiCredentialResolutionErrorKind::InvalidReference,
    454             RhiCredentialResolutionErrorKind::MissingCredential,
    455             RhiCredentialResolutionErrorKind::InsecureSecretsRoot,
    456             RhiCredentialResolutionErrorKind::InsecureCredential,
    457             RhiCredentialResolutionErrorKind::InvalidCredential,
    458             RhiCredentialResolutionErrorKind::Io,
    459             RhiCredentialResolutionErrorKind::UnsupportedPlatform,
    460         ] {
    461             let error = resolution_error(kind);
    462             assert!(!error.code().is_empty());
    463             assert!(Error::source(&error).is_none());
    464         }
    465     }
    466 }