myc

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

provider_credential.rs (18003B)


      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     MycBootstrapProfileV1, MycEncryptedIdentityEnvelopeErrorKind, MycProviderBinding,
     10     MycProviderKind, MycRuntimeContext, MycWrappingCredential,
     11     provider_envelope::load_resolved_wrapping_credential,
     12 };
     13 
     14 /// Exact fixed wrapping-credential artifact length.
     15 pub const MYC_WRAPPING_CREDENTIAL_ARTIFACT_BYTES: usize = 32;
     16 /// Exact Myc wrapping-credential resolution contract version.
     17 pub const MYC_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 MycCredentialResolutionErrorKind {
     22     InvalidBinding,
     23     UnsupportedProfile,
     24     InvalidReference,
     25     MissingCredential,
     26     InsecureSecretsRoot,
     27     InsecureCredential,
     28     InvalidCredential,
     29     Io,
     30     UnsupportedPlatform,
     31 }
     32 
     33 impl MycCredentialResolutionErrorKind {
     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 MycCredentialResolutionError {
     68     kind: MycCredentialResolutionErrorKind,
     69 }
     70 
     71 impl MycCredentialResolutionError {
     72     /// Returns the stable failure kind.
     73     #[must_use]
     74     pub const fn kind(self) -> MycCredentialResolutionErrorKind {
     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 MycCredentialResolutionError {
     86     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     87         formatter
     88             .debug_struct("MycCredentialResolutionError")
     89             .field("kind", &self.kind)
     90             .finish()
     91     }
     92 }
     93 
     94 impl fmt::Display for MycCredentialResolutionError {
     95     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     96         formatter.write_str(self.kind.message())
     97     }
     98 }
     99 
    100 impl Error for MycCredentialResolutionError {}
    101 
    102 const fn resolution_error(kind: MycCredentialResolutionErrorKind) -> MycCredentialResolutionError {
    103     MycCredentialResolutionError { 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_myc_wrapping_credential(
    112     runtime: &MycRuntimeContext,
    113     binding: &MycProviderBinding,
    114 ) -> Result<MycWrappingCredential, MycCredentialResolutionError> {
    115     if !matches!(
    116         runtime.profile(),
    117         MycBootstrapProfileV1::ServiceHost | MycBootstrapProfileV1::RepoLocal
    118     ) {
    119         return Err(resolution_error(
    120             MycCredentialResolutionErrorKind::UnsupportedProfile,
    121         ));
    122     }
    123     if binding.kind() != MycProviderKind::EncryptedFile {
    124         return Err(resolution_error(
    125             MycCredentialResolutionErrorKind::InvalidBinding,
    126         ));
    127     }
    128     let reference = binding
    129         .credential_reference()
    130         .ok_or_else(|| resolution_error(MycCredentialResolutionErrorKind::InvalidBinding))?;
    131     let name = ServiceCredentialArtifactName::new(reference.as_str())
    132         .map_err(|_| resolution_error(MycCredentialResolutionErrorKind::InvalidReference))?;
    133     let path = service_credential_artifact_path(runtime.context().paths(), &name);
    134     if binding.encrypted_envelope_path() == Some(path.as_path()) {
    135         return Err(resolution_error(
    136             MycCredentialResolutionErrorKind::InvalidBinding,
    137         ));
    138     }
    139     load_resolved_wrapping_credential(&path).map_err(|error| {
    140         let kind = match error.kind() {
    141             MycEncryptedIdentityEnvelopeErrorKind::InvalidPath
    142             | MycEncryptedIdentityEnvelopeErrorKind::InvalidBinding => {
    143                 MycCredentialResolutionErrorKind::InvalidBinding
    144             }
    145             MycEncryptedIdentityEnvelopeErrorKind::MissingEnvelope => {
    146                 MycCredentialResolutionErrorKind::MissingCredential
    147             }
    148             MycEncryptedIdentityEnvelopeErrorKind::InsecureParent => {
    149                 MycCredentialResolutionErrorKind::InsecureSecretsRoot
    150             }
    151             MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact => {
    152                 MycCredentialResolutionErrorKind::InsecureCredential
    153             }
    154             MycEncryptedIdentityEnvelopeErrorKind::InvalidCredential => {
    155                 MycCredentialResolutionErrorKind::InvalidCredential
    156             }
    157             MycEncryptedIdentityEnvelopeErrorKind::UnsupportedPlatform => {
    158                 MycCredentialResolutionErrorKind::UnsupportedPlatform
    159             }
    160             MycEncryptedIdentityEnvelopeErrorKind::InvalidProvisioningMaterial
    161             | MycEncryptedIdentityEnvelopeErrorKind::AlreadyExists
    162             | MycEncryptedIdentityEnvelopeErrorKind::UnsupportedEnvelopeVersion
    163             | MycEncryptedIdentityEnvelopeErrorKind::MalformedEnvelope
    164             | MycEncryptedIdentityEnvelopeErrorKind::WrongCredential
    165             | MycEncryptedIdentityEnvelopeErrorKind::IdentityMismatch
    166             | MycEncryptedIdentityEnvelopeErrorKind::Io => MycCredentialResolutionErrorKind::Io,
    167         };
    168         resolution_error(kind)
    169     })
    170 }
    171 
    172 #[cfg(test)]
    173 mod tests {
    174     #[cfg(any(target_os = "linux", target_os = "macos"))]
    175     use std::fs;
    176     #[cfg(any(target_os = "linux", target_os = "macos"))]
    177     use std::os::unix::fs::{PermissionsExt, symlink};
    178     use std::path::{Path, PathBuf};
    179 
    180     use nostr::{Keys, SecretKey};
    181     use sha2::{Digest, Sha256};
    182 
    183     use crate::{
    184         MycConfigProfile, MycProviderRole, RadrootsHostEnvironment, RadrootsPathResolver,
    185         RadrootsPlatform, parse_myc_cli_v1_from, parse_myc_config_v1, resolve_myc_runtime_context,
    186     };
    187 
    188     use super::*;
    189 
    190     const CONFIG: &str = include_str!("../contracts/services_hardening/config.v1.example.toml");
    191 
    192     fn bytes(label: &str) -> [u8; 32] {
    193         Sha256::digest(label.as_bytes()).into()
    194     }
    195 
    196     fn runtime(root: &Path, profile: &str) -> MycRuntimeContext {
    197         let root = root.to_str().expect("UTF-8 test root");
    198         let arguments = if profile == "repo-local" {
    199             vec![
    200                 "myc",
    201                 "--profile",
    202                 profile,
    203                 "--instance",
    204                 "primary",
    205                 "--repo-local-root",
    206                 root,
    207                 "run",
    208             ]
    209         } else {
    210             vec!["myc", "--profile", profile, "--instance", "primary", "run"]
    211         };
    212         let invocation = parse_myc_cli_v1_from(arguments).expect("test invocation");
    213         let environment = if profile == "interactive" {
    214             RadrootsHostEnvironment {
    215                 home_dir: Some(PathBuf::from(root)),
    216                 xdg_config_home: Some(PathBuf::from(root).join("config")),
    217                 xdg_data_home: Some(PathBuf::from(root).join("data")),
    218                 xdg_state_home: Some(PathBuf::from(root).join("state")),
    219                 xdg_cache_home: Some(PathBuf::from(root).join("cache")),
    220                 xdg_runtime_dir: Some(PathBuf::from(root).join("run")),
    221                 ..RadrootsHostEnvironment::default()
    222             }
    223         } else {
    224             RadrootsHostEnvironment::default()
    225         };
    226         resolve_myc_runtime_context(
    227             &RadrootsPathResolver::new(RadrootsPlatform::Linux, environment),
    228             &invocation,
    229         )
    230         .expect("runtime context")
    231     }
    232 
    233     fn binding(envelope_path: &Path) -> MycProviderBinding {
    234         let mut identity = bytes("radroots.myc.credential-test.identity.v1");
    235         while SecretKey::from_slice(&identity).is_err() {
    236             identity = Sha256::digest(identity).into();
    237         }
    238         let public_key = Keys::new(SecretKey::from_slice(&identity).expect("identity"))
    239             .public_key()
    240             .to_hex();
    241         let source = CONFIG
    242             .replace(
    243                 "/var/lib/radroots/services/myc/primary/secrets/transport.identity.ncrypt",
    244                 envelope_path.to_str().expect("UTF-8 envelope path"),
    245             )
    246             .replace(
    247                 "4444444444444444444444444444444444444444444444444444444444444444",
    248                 &public_key,
    249             );
    250         parse_myc_config_v1(source.as_bytes(), MycConfigProfile::RepoLocal)
    251             .expect("configuration")
    252             .provider_contract()
    253             .binding(MycProviderRole::Transport)
    254             .expect("transport binding")
    255             .clone()
    256     }
    257 
    258     #[cfg(any(target_os = "linux", target_os = "macos"))]
    259     fn prepare_credential(runtime: &MycRuntimeContext, name: &str, contents: &[u8]) -> PathBuf {
    260         let root = runtime.context().paths().secrets();
    261         fs::create_dir_all(root).expect("secrets root");
    262         fs::set_permissions(root, fs::Permissions::from_mode(0o700)).expect("secrets mode");
    263         let path = root.join(name);
    264         fs::write(&path, contents).expect("credential artifact");
    265         fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("credential mode");
    266         path
    267     }
    268 
    269     #[cfg(any(target_os = "linux", target_os = "macos"))]
    270     #[test]
    271     fn canonical_existing_artifact_resolves_without_path_or_value_exposure() {
    272         let directory = tempfile::tempdir().expect("test root");
    273         let runtime = runtime(directory.path(), "repo-local");
    274         let envelope_parent = directory.path().join("envelopes");
    275         fs::create_dir(&envelope_parent).expect("envelope parent");
    276         fs::set_permissions(&envelope_parent, fs::Permissions::from_mode(0o700))
    277             .expect("envelope parent mode");
    278         let binding = binding(&envelope_parent.join("transport.identity.ncrypt"));
    279         let credential_bytes = bytes("radroots.myc.credential-test.wrapping.v1");
    280         let path = prepare_credential(
    281             &runtime,
    282             binding
    283                 .credential_reference()
    284                 .expect("credential reference")
    285                 .as_str(),
    286             &credential_bytes,
    287         );
    288 
    289         let credential =
    290             resolve_myc_wrapping_credential(&runtime, &binding).expect("credential resolution");
    291         assert_eq!(
    292             format!("{credential:?}"),
    293             "MycWrappingCredential([redacted])"
    294         );
    295         assert_eq!(
    296             path,
    297             runtime
    298                 .context()
    299                 .paths()
    300                 .secrets()
    301                 .join("transport_wrapping_key")
    302         );
    303         assert_eq!(
    304             fs::read(&path).expect("credential unchanged"),
    305             credential_bytes
    306         );
    307         assert_eq!(
    308             fs::metadata(&path)
    309                 .expect("credential metadata")
    310                 .permissions()
    311                 .mode()
    312                 & 0o777,
    313             0o600
    314         );
    315         fs::set_permissions(&path, fs::Permissions::from_mode(0o400))
    316             .expect("read-only credential mode");
    317         fs::set_permissions(
    318             runtime.context().paths().secrets(),
    319             fs::Permissions::from_mode(0o500),
    320         )
    321         .expect("read-only secrets root mode");
    322         resolve_myc_wrapping_credential(&runtime, &binding)
    323             .expect("owner-read-only artifact and secrets root");
    324     }
    325 
    326     #[cfg(any(target_os = "linux", target_os = "macos"))]
    327     #[test]
    328     fn missing_adjacent_short_long_zero_and_insecure_artifacts_fail_closed() {
    329         let directory = tempfile::tempdir().expect("test root");
    330         let runtime = runtime(directory.path(), "repo-local");
    331         let envelope_parent = directory.path().join("envelopes");
    332         fs::create_dir(&envelope_parent).expect("envelope parent");
    333         fs::set_permissions(&envelope_parent, fs::Permissions::from_mode(0o700))
    334             .expect("envelope parent mode");
    335         let envelope_path = envelope_parent.join("transport.identity.ncrypt");
    336         let binding = binding(&envelope_path);
    337         let adjacent = envelope_parent.join("transport.identity.key");
    338         fs::write(&adjacent, bytes("adjacent key")).expect("adjacent file");
    339         fs::create_dir_all(runtime.context().paths().secrets()).expect("secrets root");
    340         fs::set_permissions(
    341             runtime.context().paths().secrets(),
    342             fs::Permissions::from_mode(0o700),
    343         )
    344         .expect("secrets mode");
    345         assert_eq!(
    346             resolve_myc_wrapping_credential(&runtime, &binding)
    347                 .expect_err("canonical credential is missing")
    348                 .kind(),
    349             MycCredentialResolutionErrorKind::MissingCredential
    350         );
    351 
    352         let reference = binding.credential_reference().expect("reference").as_str();
    353         let path = prepare_credential(&runtime, reference, &[1; 31]);
    354         assert_eq!(
    355             resolve_myc_wrapping_credential(&runtime, &binding)
    356                 .expect_err("short")
    357                 .kind(),
    358             MycCredentialResolutionErrorKind::InsecureCredential
    359         );
    360         fs::write(&path, [1; 33]).expect("long");
    361         assert_eq!(
    362             resolve_myc_wrapping_credential(&runtime, &binding)
    363                 .expect_err("long")
    364                 .kind(),
    365             MycCredentialResolutionErrorKind::InsecureCredential
    366         );
    367         fs::write(&path, [0; 32]).expect("zero");
    368         assert_eq!(
    369             resolve_myc_wrapping_credential(&runtime, &binding)
    370                 .expect_err("zero")
    371                 .kind(),
    372             MycCredentialResolutionErrorKind::InvalidCredential
    373         );
    374         fs::write(&path, [1; 32]).expect("valid length");
    375         fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("insecure mode");
    376         assert_eq!(
    377             resolve_myc_wrapping_credential(&runtime, &binding)
    378                 .expect_err("mode")
    379                 .kind(),
    380             MycCredentialResolutionErrorKind::InsecureCredential
    381         );
    382         fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("restore mode");
    383         let second_link = runtime.context().paths().secrets().join("second-link");
    384         fs::hard_link(&path, &second_link).expect("hard link");
    385         assert_eq!(
    386             resolve_myc_wrapping_credential(&runtime, &binding)
    387                 .expect_err("hard link")
    388                 .kind(),
    389             MycCredentialResolutionErrorKind::InsecureCredential
    390         );
    391         fs::remove_file(&second_link).expect("remove hard link");
    392         fs::remove_file(&path).expect("remove credential");
    393         symlink(&adjacent, &path).expect("credential symlink");
    394         assert_eq!(
    395             resolve_myc_wrapping_credential(&runtime, &binding)
    396                 .expect_err("symlink")
    397                 .kind(),
    398             MycCredentialResolutionErrorKind::InsecureCredential
    399         );
    400     }
    401 
    402     #[test]
    403     fn unsupported_interactive_profile_and_errors_are_source_free() {
    404         let directory = tempfile::tempdir().expect("test root");
    405         let runtime = runtime(directory.path(), "interactive");
    406         let binding = binding(&directory.path().join("transport.identity.ncrypt"));
    407         let error =
    408             resolve_myc_wrapping_credential(&runtime, &binding).expect_err("interactive profile");
    409         assert_eq!(
    410             error.kind(),
    411             MycCredentialResolutionErrorKind::UnsupportedProfile
    412         );
    413         assert!(Error::source(&error).is_none());
    414         let rendered = format!("{error} {error:?}");
    415         assert!(!rendered.contains(directory.path().to_string_lossy().as_ref()));
    416         for kind in [
    417             MycCredentialResolutionErrorKind::InvalidBinding,
    418             MycCredentialResolutionErrorKind::UnsupportedProfile,
    419             MycCredentialResolutionErrorKind::InvalidReference,
    420             MycCredentialResolutionErrorKind::MissingCredential,
    421             MycCredentialResolutionErrorKind::InsecureSecretsRoot,
    422             MycCredentialResolutionErrorKind::InsecureCredential,
    423             MycCredentialResolutionErrorKind::InvalidCredential,
    424             MycCredentialResolutionErrorKind::Io,
    425             MycCredentialResolutionErrorKind::UnsupportedPlatform,
    426         ] {
    427             let error = resolution_error(kind);
    428             assert!(!error.code().is_empty());
    429             assert!(Error::source(&error).is_none());
    430         }
    431     }
    432 }