myc

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

commit da49a77185dee9ee8f61562ddc2245b37cc05c5d
parent b4dbe0642760ac930706bbf00a0c410f862ddcea
Author: triesap <tyson@radroots.org>
Date:   Fri, 21 Aug 2026 20:56:19 +0000

provider: resolve canonical wrapping credentials

Diffstat:
MAGENTS.md | 8++++++++
MREADME | 10++++++++++
Acontracts/services_hardening/wrapping_credential_resolution.v1.json | 41+++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 8+++++++-
Asrc/provider_credential.rs | 432+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/provider_envelope.rs | 74+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Atests/services_hardening_credential_resolution.rs | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/services_hardening_encrypted_envelope.rs | 4++--
8 files changed, 716 insertions(+), 14 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -49,6 +49,14 @@ caller-supplied entropy, create-new owner-only persistence, expected-public- key verification, and state-backup exclusion. Credential artifact resolution remains a separate boundary and may not introduce a sibling-key fallback. +- Wrapping-credential resolution is frozen by + `contracts/services_hardening/wrapping_credential_resolution.v1.json`. It + derives one validated shared artifact name beneath the canonical instance + secrets root, reads only an existing exact owner-only artifact, and exposes + neither a caller path nor caller bytes. Production injection/mounting and + repo-local provisioning are external/offline; ordinary run, TOML, + environment, arguments, envelope siblings, and state backup never create or + carry the credential. - Treat checked-in source, tests, and prototype behavior as implementation evidence, not permission to preserve behavior that the active requirement removes. diff --git a/README b/README @@ -98,6 +98,16 @@ wrapping credentials, and plaintext identity material are excluded. Canonical credential artifact resolution remains Step 133 and ordinary run never provisions an identity. +Wrapping credentials now resolve only from the shared validated +`ServiceCredentialArtifactName` beneath the sealed runtime context's canonical +instance secrets root. The resolver accepts no path or credential bytes and +reads only an existing exact 32-byte, euid-owned, single-link artifact through +descriptor-relative no-follow admission. Service-host deployments inject or +mount that fixed file; repo-local developers provision it offline. Interactive +resolution is unsupported, and TOML, environment, process arguments, adjacent +envelope files, ordinary run, and state backup neither carry nor create the +credential. + Signer-request admission validates bounded client, request, event, method, canonical request, injected operation entropy, and injected time evidence before storage. Stable domain-separated operation and correlation identities diff --git a/contracts/services_hardening/wrapping_credential_resolution.v1.json b/contracts/services_hardening/wrapping_credential_resolution.v1.json @@ -0,0 +1,41 @@ +{ + "schema": "radroots.myc.wrapping-credential-resolution", + "schema_version": 1, + "contract_version": 1, + "artifact_name": { + "shared_type": "ServiceCredentialArtifactName", + "maximum_utf8_bytes": 128 + }, + "artifact_path": "<canonical_instance_secrets>/<validated_credential_name>", + "artifact": { + "wire": "raw_32_bytes", + "exact_bytes": 32, + "symlink_follow": false, + "regular_file": true, + "single_link": true, + "owner_uid": "effective_uid", + "read_modes_octal": ["0400", "0600"], + "secrets_root_modes_octal": ["0500", "0700"] + }, + "profiles": { + "service_host": "existing_injected_or_mounted", + "repo_local": "existing_offline_provisioned", + "interactive": "unsupported" + }, + "resolution": { + "read_existing_only": true, + "creates_credential": false, + "creates_parent": false, + "caller_supplies_path": false, + "caller_supplies_bytes": false, + "ordinary_run_generates": false + }, + "forbidden_sources": [ + "toml_secret", + "environment_secret", + "process_argument_secret", + "adjacent_envelope_sibling", + "implicit_fallback" + ], + "backup_included": false +} diff --git a/src/lib.rs b/src/lib.rs @@ -3,6 +3,7 @@ mod cli_v1; mod config_v1; mod provider_contract; +mod provider_credential; mod provider_envelope; mod runtime_context; mod state_catalog; @@ -36,9 +37,14 @@ pub use provider_contract::{ MycProviderOperationInput, MycProviderPublicIdentity, MycProviderRole, MycUntrustedProviderOutput, }; +pub use provider_credential::{ + MYC_WRAPPING_CREDENTIAL_ARTIFACT_BYTES, MYC_WRAPPING_CREDENTIAL_CONTRACT_VERSION, + MycCredentialResolutionError, MycCredentialResolutionErrorKind, + resolve_myc_wrapping_credential, +}; pub use provider_envelope::{ MYC_ENCRYPTED_IDENTITY_BACKUP_INCLUDED, MYC_ENCRYPTED_IDENTITY_ENVELOPE_CONTRACT_VERSION, - MYC_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES, MycCredentialResolutionProof, MycDecryptedIdentity, + MYC_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES, MycDecryptedIdentity, MycEncryptedIdentityEnvelopeError, MycEncryptedIdentityEnvelopeErrorKind, MycEncryptedIdentityProvisioningMaterial, MycWrappingCredential, open_myc_encrypted_identity, provision_myc_encrypted_identity, diff --git a/src/provider_credential.rs b/src/provider_credential.rs @@ -0,0 +1,432 @@ +//! Canonical wrapping-credential artifact resolution. + +use core::fmt; +use std::error::Error; + +use radroots_runtime_paths::{ServiceCredentialArtifactName, service_credential_artifact_path}; + +use crate::{ + MycBootstrapProfileV1, MycEncryptedIdentityEnvelopeErrorKind, MycProviderBinding, + MycProviderKind, MycRuntimeContext, MycWrappingCredential, + provider_envelope::load_resolved_wrapping_credential, +}; + +/// Exact fixed wrapping-credential artifact length. +pub const MYC_WRAPPING_CREDENTIAL_ARTIFACT_BYTES: usize = 32; +/// Exact Myc wrapping-credential resolution contract version. +pub const MYC_WRAPPING_CREDENTIAL_CONTRACT_VERSION: u32 = 1; + +/// Stable source-free credential-resolution failure classification. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycCredentialResolutionErrorKind { + InvalidBinding, + UnsupportedProfile, + InvalidReference, + MissingCredential, + InsecureSecretsRoot, + InsecureCredential, + InvalidCredential, + Io, + UnsupportedPlatform, +} + +impl MycCredentialResolutionErrorKind { + /// Returns the stable machine-facing safe code. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::InvalidBinding => "provider_credential_binding_invalid", + Self::UnsupportedProfile => "provider_credential_profile_unsupported", + Self::InvalidReference => "provider_credential_reference_invalid", + Self::MissingCredential => "provider_credential_missing", + Self::InsecureSecretsRoot => "provider_credential_root_insecure", + Self::InsecureCredential => "provider_credential_artifact_insecure", + Self::InvalidCredential => "provider_credential_material_invalid", + Self::Io => "provider_credential_io_failed", + Self::UnsupportedPlatform => "provider_credential_platform_unsupported", + } + } + + const fn message(self) -> &'static str { + match self { + Self::InvalidBinding => "provider credential binding is invalid", + Self::UnsupportedProfile => "provider credential profile is unsupported", + Self::InvalidReference => "provider credential reference is invalid", + Self::MissingCredential => "provider credential is missing", + Self::InsecureSecretsRoot => "provider credential root is insecure", + Self::InsecureCredential => "provider credential artifact is insecure", + Self::InvalidCredential => "provider credential material is invalid", + Self::Io => "provider credential storage failed", + Self::UnsupportedPlatform => "provider credential storage is unsupported", + } + } +} + +/// One source-free wrapping-credential resolution failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct MycCredentialResolutionError { + kind: MycCredentialResolutionErrorKind, +} + +impl MycCredentialResolutionError { + /// Returns the stable failure kind. + #[must_use] + pub const fn kind(self) -> MycCredentialResolutionErrorKind { + self.kind + } + + /// Returns the stable machine-facing safe code. + #[must_use] + pub const fn code(self) -> &'static str { + self.kind.code() + } +} + +impl fmt::Debug for MycCredentialResolutionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycCredentialResolutionError") + .field("kind", &self.kind) + .finish() + } +} + +impl fmt::Display for MycCredentialResolutionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.kind.message()) + } +} + +impl Error for MycCredentialResolutionError {} + +const fn resolution_error(kind: MycCredentialResolutionErrorKind) -> MycCredentialResolutionError { + MycCredentialResolutionError { kind } +} + +/// Resolves one existing wrapping credential from the canonical instance secrets root. +/// +/// The caller supplies no path or credential bytes. Production deployment and +/// repo-local offline tooling provision the fixed artifact externally; this +/// operation is read-only and never creates a credential or parent directory. +pub fn resolve_myc_wrapping_credential( + runtime: &MycRuntimeContext, + binding: &MycProviderBinding, +) -> Result<MycWrappingCredential, MycCredentialResolutionError> { + if !matches!( + runtime.profile(), + MycBootstrapProfileV1::ServiceHost | MycBootstrapProfileV1::RepoLocal + ) { + return Err(resolution_error( + MycCredentialResolutionErrorKind::UnsupportedProfile, + )); + } + if binding.kind() != MycProviderKind::EncryptedFile { + return Err(resolution_error( + MycCredentialResolutionErrorKind::InvalidBinding, + )); + } + let reference = binding + .credential_reference() + .ok_or_else(|| resolution_error(MycCredentialResolutionErrorKind::InvalidBinding))?; + let name = ServiceCredentialArtifactName::new(reference.as_str()) + .map_err(|_| resolution_error(MycCredentialResolutionErrorKind::InvalidReference))?; + let path = service_credential_artifact_path(runtime.context().paths(), &name); + if binding.encrypted_envelope_path() == Some(path.as_path()) { + return Err(resolution_error( + MycCredentialResolutionErrorKind::InvalidBinding, + )); + } + load_resolved_wrapping_credential(&path).map_err(|error| { + let kind = match error.kind() { + MycEncryptedIdentityEnvelopeErrorKind::InvalidPath + | MycEncryptedIdentityEnvelopeErrorKind::InvalidBinding => { + MycCredentialResolutionErrorKind::InvalidBinding + } + MycEncryptedIdentityEnvelopeErrorKind::MissingEnvelope => { + MycCredentialResolutionErrorKind::MissingCredential + } + MycEncryptedIdentityEnvelopeErrorKind::InsecureParent => { + MycCredentialResolutionErrorKind::InsecureSecretsRoot + } + MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact => { + MycCredentialResolutionErrorKind::InsecureCredential + } + MycEncryptedIdentityEnvelopeErrorKind::InvalidCredential => { + MycCredentialResolutionErrorKind::InvalidCredential + } + MycEncryptedIdentityEnvelopeErrorKind::UnsupportedPlatform => { + MycCredentialResolutionErrorKind::UnsupportedPlatform + } + MycEncryptedIdentityEnvelopeErrorKind::InvalidProvisioningMaterial + | MycEncryptedIdentityEnvelopeErrorKind::AlreadyExists + | MycEncryptedIdentityEnvelopeErrorKind::UnsupportedEnvelopeVersion + | MycEncryptedIdentityEnvelopeErrorKind::MalformedEnvelope + | MycEncryptedIdentityEnvelopeErrorKind::WrongCredential + | MycEncryptedIdentityEnvelopeErrorKind::IdentityMismatch + | MycEncryptedIdentityEnvelopeErrorKind::Io => MycCredentialResolutionErrorKind::Io, + }; + resolution_error(kind) + }) +} + +#[cfg(test)] +mod tests { + #[cfg(any(target_os = "linux", target_os = "macos"))] + use std::fs; + #[cfg(any(target_os = "linux", target_os = "macos"))] + use std::os::unix::fs::{PermissionsExt, symlink}; + use std::path::{Path, PathBuf}; + + use nostr::{Keys, SecretKey}; + use sha2::{Digest, Sha256}; + + use crate::{ + MycConfigProfile, MycProviderRole, RadrootsHostEnvironment, RadrootsPathResolver, + RadrootsPlatform, parse_myc_cli_v1_from, parse_myc_config_v1, resolve_myc_runtime_context, + }; + + use super::*; + + const CONFIG: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); + + fn bytes(label: &str) -> [u8; 32] { + Sha256::digest(label.as_bytes()).into() + } + + fn runtime(root: &Path, profile: &str) -> MycRuntimeContext { + let root = root.to_str().expect("UTF-8 test root"); + let arguments = if profile == "repo-local" { + vec![ + "myc", + "--profile", + profile, + "--instance", + "primary", + "--repo-local-root", + root, + "run", + ] + } else { + vec!["myc", "--profile", profile, "--instance", "primary", "run"] + }; + let invocation = parse_myc_cli_v1_from(arguments).expect("test invocation"); + let environment = if profile == "interactive" { + RadrootsHostEnvironment { + home_dir: Some(PathBuf::from(root)), + xdg_config_home: Some(PathBuf::from(root).join("config")), + xdg_data_home: Some(PathBuf::from(root).join("data")), + xdg_state_home: Some(PathBuf::from(root).join("state")), + xdg_cache_home: Some(PathBuf::from(root).join("cache")), + xdg_runtime_dir: Some(PathBuf::from(root).join("run")), + ..RadrootsHostEnvironment::default() + } + } else { + RadrootsHostEnvironment::default() + }; + resolve_myc_runtime_context( + &RadrootsPathResolver::new(RadrootsPlatform::Linux, environment), + &invocation, + ) + .expect("runtime context") + } + + fn binding(envelope_path: &Path) -> MycProviderBinding { + let mut identity = bytes("radroots.myc.credential-test.identity.v1"); + while SecretKey::from_slice(&identity).is_err() { + identity = Sha256::digest(identity).into(); + } + let public_key = Keys::new(SecretKey::from_slice(&identity).expect("identity")) + .public_key() + .to_hex(); + let source = CONFIG + .replace( + "/var/lib/radroots/services/myc/primary/secrets/transport.identity.ncrypt", + envelope_path.to_str().expect("UTF-8 envelope path"), + ) + .replace( + "4444444444444444444444444444444444444444444444444444444444444444", + &public_key, + ); + parse_myc_config_v1(source.as_bytes(), MycConfigProfile::RepoLocal) + .expect("configuration") + .provider_contract() + .binding(MycProviderRole::Transport) + .expect("transport binding") + .clone() + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn prepare_credential(runtime: &MycRuntimeContext, name: &str, contents: &[u8]) -> PathBuf { + let root = runtime.context().paths().secrets(); + fs::create_dir_all(root).expect("secrets root"); + fs::set_permissions(root, fs::Permissions::from_mode(0o700)).expect("secrets mode"); + let path = root.join(name); + fs::write(&path, contents).expect("credential artifact"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("credential mode"); + path + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn canonical_existing_artifact_resolves_without_path_or_value_exposure() { + let directory = tempfile::tempdir().expect("test root"); + let runtime = runtime(directory.path(), "repo-local"); + let envelope_parent = directory.path().join("envelopes"); + fs::create_dir(&envelope_parent).expect("envelope parent"); + fs::set_permissions(&envelope_parent, fs::Permissions::from_mode(0o700)) + .expect("envelope parent mode"); + let binding = binding(&envelope_parent.join("transport.identity.ncrypt")); + let credential_bytes = bytes("radroots.myc.credential-test.wrapping.v1"); + let path = prepare_credential( + &runtime, + binding + .credential_reference() + .expect("credential reference") + .as_str(), + &credential_bytes, + ); + + let credential = + resolve_myc_wrapping_credential(&runtime, &binding).expect("credential resolution"); + assert_eq!( + format!("{credential:?}"), + "MycWrappingCredential([redacted])" + ); + assert_eq!( + path, + runtime + .context() + .paths() + .secrets() + .join("transport_wrapping_key") + ); + assert_eq!( + fs::read(&path).expect("credential unchanged"), + credential_bytes + ); + assert_eq!( + fs::metadata(&path) + .expect("credential metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); + fs::set_permissions(&path, fs::Permissions::from_mode(0o400)) + .expect("read-only credential mode"); + fs::set_permissions( + runtime.context().paths().secrets(), + fs::Permissions::from_mode(0o500), + ) + .expect("read-only secrets root mode"); + resolve_myc_wrapping_credential(&runtime, &binding) + .expect("owner-read-only artifact and secrets root"); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn missing_adjacent_short_long_zero_and_insecure_artifacts_fail_closed() { + let directory = tempfile::tempdir().expect("test root"); + let runtime = runtime(directory.path(), "repo-local"); + let envelope_parent = directory.path().join("envelopes"); + fs::create_dir(&envelope_parent).expect("envelope parent"); + fs::set_permissions(&envelope_parent, fs::Permissions::from_mode(0o700)) + .expect("envelope parent mode"); + let envelope_path = envelope_parent.join("transport.identity.ncrypt"); + let binding = binding(&envelope_path); + let adjacent = envelope_parent.join("transport.identity.key"); + fs::write(&adjacent, bytes("adjacent key")).expect("adjacent file"); + fs::create_dir_all(runtime.context().paths().secrets()).expect("secrets root"); + fs::set_permissions( + runtime.context().paths().secrets(), + fs::Permissions::from_mode(0o700), + ) + .expect("secrets mode"); + assert_eq!( + resolve_myc_wrapping_credential(&runtime, &binding) + .expect_err("canonical credential is missing") + .kind(), + MycCredentialResolutionErrorKind::MissingCredential + ); + + let reference = binding.credential_reference().expect("reference").as_str(); + let path = prepare_credential(&runtime, reference, &[1; 31]); + assert_eq!( + resolve_myc_wrapping_credential(&runtime, &binding) + .expect_err("short") + .kind(), + MycCredentialResolutionErrorKind::InsecureCredential + ); + fs::write(&path, [1; 33]).expect("long"); + assert_eq!( + resolve_myc_wrapping_credential(&runtime, &binding) + .expect_err("long") + .kind(), + MycCredentialResolutionErrorKind::InsecureCredential + ); + fs::write(&path, [0; 32]).expect("zero"); + assert_eq!( + resolve_myc_wrapping_credential(&runtime, &binding) + .expect_err("zero") + .kind(), + MycCredentialResolutionErrorKind::InvalidCredential + ); + fs::write(&path, [1; 32]).expect("valid length"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("insecure mode"); + assert_eq!( + resolve_myc_wrapping_credential(&runtime, &binding) + .expect_err("mode") + .kind(), + MycCredentialResolutionErrorKind::InsecureCredential + ); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("restore mode"); + let second_link = runtime.context().paths().secrets().join("second-link"); + fs::hard_link(&path, &second_link).expect("hard link"); + assert_eq!( + resolve_myc_wrapping_credential(&runtime, &binding) + .expect_err("hard link") + .kind(), + MycCredentialResolutionErrorKind::InsecureCredential + ); + fs::remove_file(&second_link).expect("remove hard link"); + fs::remove_file(&path).expect("remove credential"); + symlink(&adjacent, &path).expect("credential symlink"); + assert_eq!( + resolve_myc_wrapping_credential(&runtime, &binding) + .expect_err("symlink") + .kind(), + MycCredentialResolutionErrorKind::InsecureCredential + ); + } + + #[test] + fn unsupported_interactive_profile_and_errors_are_source_free() { + let directory = tempfile::tempdir().expect("test root"); + let runtime = runtime(directory.path(), "interactive"); + let binding = binding(&directory.path().join("transport.identity.ncrypt")); + let error = + resolve_myc_wrapping_credential(&runtime, &binding).expect_err("interactive profile"); + assert_eq!( + error.kind(), + MycCredentialResolutionErrorKind::UnsupportedProfile + ); + assert!(Error::source(&error).is_none()); + let rendered = format!("{error} {error:?}"); + assert!(!rendered.contains(directory.path().to_string_lossy().as_ref())); + for kind in [ + MycCredentialResolutionErrorKind::InvalidBinding, + MycCredentialResolutionErrorKind::UnsupportedProfile, + MycCredentialResolutionErrorKind::InvalidReference, + MycCredentialResolutionErrorKind::MissingCredential, + MycCredentialResolutionErrorKind::InsecureSecretsRoot, + MycCredentialResolutionErrorKind::InsecureCredential, + MycCredentialResolutionErrorKind::InvalidCredential, + MycCredentialResolutionErrorKind::Io, + MycCredentialResolutionErrorKind::UnsupportedPlatform, + ] { + let error = resolution_error(kind); + assert!(!error.code().is_empty()); + assert!(Error::source(&error).is_none()); + } + } +} diff --git a/src/provider_envelope.rs b/src/provider_envelope.rs @@ -157,7 +157,7 @@ const fn envelope_error( pub struct MycWrappingCredential(Zeroizing<[u8; WRAPPING_CREDENTIAL_BYTES]>); /// Non-forgeable proof that owns credential bytes admitted by the governed resolver. -pub struct MycCredentialResolutionProof { +pub(crate) struct MycCredentialResolutionProof { credential: Zeroizing<[u8; WRAPPING_CREDENTIAL_BYTES]>, } @@ -168,8 +168,7 @@ impl fmt::Debug for MycCredentialResolutionProof { } impl MycWrappingCredential { - /// Consumes exact credential bytes sealed inside governed resolution proof. - pub fn from_resolution( + pub(crate) fn from_resolution( proof: MycCredentialResolutionProof, ) -> Result<Self, MycEncryptedIdentityEnvelopeError> { if proof.credential.iter().all(|byte| *byte == 0) { @@ -334,6 +333,17 @@ pub fn open_myc_encrypted_identity( )) } +pub(crate) fn load_resolved_wrapping_credential( + path: &Path, +) -> Result<MycWrappingCredential, MycEncryptedIdentityEnvelopeError> { + ensure_supported_platform()?; + validate_requested_path(path)?; + let encoded = Zeroizing::new(read_existing_exact(path, WRAPPING_CREDENTIAL_BYTES)?); + let mut credential = Zeroizing::new([0_u8; WRAPPING_CREDENTIAL_BYTES]); + credential.copy_from_slice(&encoded); + MycWrappingCredential::from_resolution(MycCredentialResolutionProof { credential }) +} + fn require_wire_version(encoded: &[u8]) -> Result<(), MycEncryptedIdentityEnvelopeError> { if encoded.len() < 6 || &encoded[..4] != b"RRS1" { return Err(envelope_error( @@ -729,7 +739,11 @@ mod native { file.write_all(encoded) .and_then(|()| file.sync_all()) .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::Io))?; - file_identity(&file, Some(encoded.len()))?; + file_identity( + &file, + Some(encoded.len()), + MYC_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES, + )?; validate_current_binding( &path, &parent, @@ -737,6 +751,7 @@ mod native { &file, identity, encoded.len(), + MYC_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES, )?; parent .sync_all() @@ -748,6 +763,7 @@ mod native { &file, identity, encoded.len(), + MYC_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES, ) })(); if result.is_err() { @@ -757,6 +773,21 @@ mod native { } pub(super) fn read_existing(path: &Path) -> Result<Vec<u8>, MycEncryptedIdentityEnvelopeError> { + read_existing_bounded(path, MYC_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES, None) + } + + pub(super) fn read_existing_exact( + path: &Path, + expected_length: usize, + ) -> Result<Vec<u8>, MycEncryptedIdentityEnvelopeError> { + read_existing_bounded(path, expected_length, Some(expected_length)) + } + + fn read_existing_bounded( + path: &Path, + maximum_length: usize, + expected_length: Option<usize>, + ) -> Result<Vec<u8>, MycEncryptedIdentityEnvelopeError> { let path = ArtifactPath::parse(path)?; let parent = open_parent(&path.parent_path, false)?; let parent_identity = directory_identity(&parent, false)?; @@ -778,7 +809,7 @@ mod native { let mut file = File::from(descriptor); let status = fstat(&file) .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact))?; - let length = validate_file_status(&status, None)?; + let length = validate_file_status(&status, expected_length, maximum_length)?; let identity = status_identity( &status, MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact, @@ -795,7 +826,15 @@ mod native { MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact, )); } - validate_current_binding(&path, &parent, parent_identity, &file, identity, length)?; + validate_current_binding( + &path, + &parent, + parent_identity, + &file, + identity, + length, + maximum_length, + )?; Ok(encoded) } @@ -854,10 +893,11 @@ mod native { fn file_identity( file: &File, expected_length: Option<usize>, + maximum_length: usize, ) -> Result<Identity, MycEncryptedIdentityEnvelopeError> { let status = fstat(file) .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact))?; - validate_file_status(&status, expected_length)?; + validate_file_status(&status, expected_length, maximum_length)?; status_identity( &status, MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact, @@ -889,6 +929,7 @@ mod native { fn validate_file_status( status: &rustix::fs::Stat, expected_length: Option<usize>, + maximum_length: usize, ) -> Result<usize, MycEncryptedIdentityEnvelopeError> { let mode = native_mode(status.st_mode) & 0o777; let length = usize::try_from(status.st_size) @@ -898,7 +939,7 @@ mod native { || status.st_uid != geteuid().as_raw() || !matches!(mode, 0o400 | 0o600) || length == 0 - || length > MYC_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES + || length > maximum_length || expected_length.is_some_and(|expected| expected != length) { return Err(envelope_error( @@ -915,6 +956,7 @@ mod native { held_file: &File, expected_file: Identity, expected_length: usize, + maximum_length: usize, ) -> Result<(), MycEncryptedIdentityEnvelopeError> { let current_parent = open_parent(&path.parent_path, false)?; if directory_identity(held_parent, false)? != expected_parent @@ -933,8 +975,8 @@ mod native { ) .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact))?, ); - if file_identity(held_file, Some(expected_length))? != expected_file - || file_identity(&current_file, Some(expected_length))? != expected_file + if file_identity(held_file, Some(expected_length), maximum_length)? != expected_file + || file_identity(&current_file, Some(expected_length), maximum_length)? != expected_file { return Err(envelope_error( MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact, @@ -984,7 +1026,7 @@ mod native { } #[cfg(any(target_os = "linux", target_os = "macos"))] -use native::{persist_create_new, read_existing, validate_requested_path}; +use native::{persist_create_new, read_existing, read_existing_exact, validate_requested_path}; #[cfg(any(target_os = "linux", target_os = "macos"))] const fn ensure_supported_platform() -> Result<(), MycEncryptedIdentityEnvelopeError> { @@ -1022,6 +1064,16 @@ fn read_existing(_path: &Path) -> Result<Vec<u8>, MycEncryptedIdentityEnvelopeEr )) } +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn read_existing_exact( + _path: &Path, + _expected_length: usize, +) -> Result<Vec<u8>, MycEncryptedIdentityEnvelopeError> { + Err(envelope_error( + MycEncryptedIdentityEnvelopeErrorKind::UnsupportedPlatform, + )) +} + #[cfg(test)] mod tests { #[cfg(any(target_os = "linux", target_os = "macos"))] diff --git a/tests/services_hardening_credential_resolution.rs b/tests/services_hardening_credential_resolution.rs @@ -0,0 +1,153 @@ +#![forbid(unsafe_code)] + +use serde_json::json; + +const CONTRACT: &str = + include_str!("../contracts/services_hardening/wrapping_credential_resolution.v1.json"); +const LIB_SOURCE: &str = include_str!("../src/lib.rs"); +const CREDENTIAL_SOURCE: &str = include_str!("../src/provider_credential.rs"); +const ENVELOPE_SOURCE: &str = include_str!("../src/provider_envelope.rs"); +const CONFIG_SCHEMA: &str = include_str!("../contracts/services_hardening/config.v1.schema.json"); +const CONFIG_EXAMPLE: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); +const HOST_SOURCE: &str = include_str!("../src/state_host.rs"); +const MAINTENANCE_SOURCE: &str = include_str!("../src/state_maintenance.rs"); + +#[test] +fn machine_contract_freezes_the_canonical_read_only_credential_boundary() { + let actual: serde_json::Value = serde_json::from_str(CONTRACT).expect("contract JSON"); + assert_eq!( + actual, + json!({ + "schema": "radroots.myc.wrapping-credential-resolution", + "schema_version": 1, + "contract_version": 1, + "artifact_name": { + "shared_type": "ServiceCredentialArtifactName", + "maximum_utf8_bytes": 128 + }, + "artifact_path": "<canonical_instance_secrets>/<validated_credential_name>", + "artifact": { + "wire": "raw_32_bytes", + "exact_bytes": 32, + "symlink_follow": false, + "regular_file": true, + "single_link": true, + "owner_uid": "effective_uid", + "read_modes_octal": ["0400", "0600"], + "secrets_root_modes_octal": ["0500", "0700"] + }, + "profiles": { + "service_host": "existing_injected_or_mounted", + "repo_local": "existing_offline_provisioned", + "interactive": "unsupported" + }, + "resolution": { + "read_existing_only": true, + "creates_credential": false, + "creates_parent": false, + "caller_supplies_path": false, + "caller_supplies_bytes": false, + "ordinary_run_generates": false + }, + "forbidden_sources": [ + "toml_secret", + "environment_secret", + "process_argument_secret", + "adjacent_envelope_sibling", + "implicit_fallback" + ], + "backup_included": false + }) + ); +} + +#[test] +fn implementation_derives_only_the_shared_canonical_artifact() { + for required in [ + "ServiceCredentialArtifactName::new(reference.as_str())", + "service_credential_artifact_path(runtime.context().paths(), &name)", + "load_resolved_wrapping_credential(&path)", + "MycBootstrapProfileV1::ServiceHost | MycBootstrapProfileV1::RepoLocal", + "pub fn resolve_myc_wrapping_credential(", + ] { + assert!( + CREDENTIAL_SOURCE.contains(required), + "missing canonical resolution boundary {required}" + ); + } + assert!(LIB_SOURCE.contains("mod provider_credential;")); + assert!(!LIB_SOURCE.contains("pub mod provider_credential")); + assert!(ENVELOPE_SOURCE.contains("pub(crate) struct MycCredentialResolutionProof")); + assert!(ENVELOPE_SOURCE.contains("pub(crate) fn from_resolution(")); +} + +#[test] +fn no_configuration_process_sibling_or_creation_fallback_exists() { + let production = CREDENTIAL_SOURCE + .split("#[cfg(test)]") + .next() + .expect("production source"); + for forbidden in [ + "std::env::", + "process::Command", + "clap::", + "create_dir", + "create_new", + "OpenOptions", + "keyring::", + "with_extension(\"key\")", + "set_var(", + "var_os(", + ] { + assert!( + !production.contains(forbidden), + "forbidden credential authority {forbidden}" + ); + } + for source in [CONFIG_SCHEMA, CONFIG_EXAMPLE] { + for forbidden in [ + "wrapping_credential =", + "credential_bytes", + "credential_hex", + ] { + assert!(!source.contains(forbidden)); + } + } +} + +#[test] +fn state_backup_and_runtime_sources_remain_credential_free() { + for source in [HOST_SOURCE, MAINTENANCE_SOURCE] { + for forbidden in [ + "resolve_myc_wrapping_credential", + "MycWrappingCredential", + "provider_credential", + "transport_wrapping_key", + ] { + assert!(!source.contains(forbidden)); + } + } +} + +#[test] +fn public_errors_are_source_path_and_dependency_free() { + for required in [ + "pub enum MycCredentialResolutionErrorKind", + "pub struct MycCredentialResolutionError", + "impl Error for MycCredentialResolutionError {}", + ] { + assert!(CREDENTIAL_SOURCE.contains(required)); + } + for forbidden in [ + "pub path:", + "pub source:", + "pub credential:", + "pub fn credential_path", + "pub fn from_resolved_bytes", + "pub fn from_resolution", + "radroots_runtime_paths::ServiceCredentialArtifactNameError", + "rustix::", + ] { + assert!(!LIB_SOURCE.contains(forbidden)); + } +} diff --git a/tests/services_hardening_encrypted_envelope.rs b/tests/services_hardening_encrypted_envelope.rs @@ -122,12 +122,10 @@ fn public_error_and_protected_types_are_dependency_and_path_free() { "pub enum MycEncryptedIdentityEnvelopeErrorKind", "pub struct MycEncryptedIdentityEnvelopeError", "pub struct MycWrappingCredential(", - "pub struct MycCredentialResolutionProof", "pub struct MycEncryptedIdentityProvisioningMaterial", "pub struct MycDecryptedIdentity", "formatter.write_str(\"MycWrappingCredential([redacted])\")", "impl Error for MycEncryptedIdentityEnvelopeError {}", - "pub fn from_resolution(\n proof: MycCredentialResolutionProof,", ] { assert!( ENVELOPE_SOURCE.contains(required), @@ -144,6 +142,8 @@ fn public_error_and_protected_types_are_dependency_and_path_free() { "pub wrapping_nonce:", "pub fn expose_secret", "pub fn encrypted_envelope_path", + "pub struct MycCredentialResolutionProof", + "pub fn from_resolution", ] { assert!(!ENVELOPE_SOURCE.contains(forbidden)); }