myc

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

commit b4dbe0642760ac930706bbf00a0c410f862ddcea
parent a70da66e3a4fc606313021c8e9964a9a8e4985eb
Author: triesap <tyson@radroots.org>
Date:   Fri, 21 Aug 2026 20:38:06 +0000

provider: implement encrypted identity envelope

Diffstat:
MAGENTS.md | 6++++++
MCargo.lock | 1+
MCargo.toml | 1+
MREADME | 13+++++++++++++
Acontracts/services_hardening/encrypted_identity_envelope.v1.json | 43+++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 8++++++++
Msrc/provider_contract.rs | 7+++++++
Asrc/provider_envelope.rs | 1380+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/services_hardening_encrypted_envelope.rs | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 1609 insertions(+), 0 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -43,6 +43,12 @@ models. Provider wire encoding and result verification may refine only the later checkpoints that own those boundaries; they may not widen this contract. +- The encrypted-file implementation is frozen by + `contracts/services_hardening/encrypted_identity_envelope.v1.json`. It must + use the source-locked `radroots_secrets` v2 context-bound envelope, explicit + 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. - Treat checked-in source, tests, and prototype behavior as implementation evidence, not permission to preserve behavior that the active requirement removes. diff --git a/Cargo.lock b/Cargo.lock @@ -1548,6 +1548,7 @@ dependencies = [ "radroots_signing", "radroots_storage", "rand 0.9.2", + "rustix", "serde", "serde_json", "serial_test", diff --git a/Cargo.toml b/Cargo.toml @@ -53,6 +53,7 @@ serde_json = "1.0" sha2 = "0.10" sqlx = { version = "0.9.0", default-features = false, features = ["derive", "sqlite-bundled"] } rand = "0.9" +rustix = { version = "1", features = ["fs", "process", "std"] } thiserror = "2.0" tempfile = "3.17" tokio = { version = "1.48", default-features = false, features = ["io-util", "macros", "net", "process", "rt-multi-thread", "sync", "time"] } diff --git a/README b/README @@ -85,6 +85,19 @@ ordinary Debug, and performs no filesystem, credential, socket, clock, task, or cryptographic work. Envelope execution, credential resolution, Unix-socket transport, and independent result verification remain later checkpoints. +The encrypted-file provider now uses the source-locked `radroots_secrets` v2 +context-bound envelope behind a sealed Myc boundary. Offline provisioning +requires explicit identity, data-key, envelope-nonce, and wrapping-nonce +material; verifies the derived public key before persistence; and creates one +owner-only envelope without overwrite. Existing reads are bounded, no-follow, +single-link, owner-only, context/reference checked, and independently verify +the decrypted public key against the configured role identity. Protected +material is zeroizing and absent from diagnostics. The service-state backup +inventory remains exactly `state.sqlite`: encrypted identity envelopes, +wrapping credentials, and plaintext identity material are excluded. Canonical +credential artifact resolution remains Step 133 and ordinary run never +provisions an identity. + 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/encrypted_identity_envelope.v1.json b/contracts/services_hardening/encrypted_identity_envelope.v1.json @@ -0,0 +1,43 @@ +{ + "schema": "radroots.myc.encrypted-identity-envelope", + "schema_version": 1, + "contract_version": 1, + "radroots_secrets_envelope_version": 2, + "encoded_max_bytes": 262144, + "identity_secret_bytes": 32, + "wrapping_credential_bytes": 32, + "provisioning_entropy": { + "data_key_bytes": 32, + "envelope_nonce_bytes": 24, + "wrapping_nonce_bytes": 24, + "caller_supplied": true + }, + "authenticated_context": { + "purpose": "radroots.myc.encrypted_identity", + "subject_type": "provider_identity", + "subject_value": "<role>:<expected_public_key>", + "payload_schema": "radroots.myc.identity_secret.v1", + "credential_reference_bound": true + }, + "artifact": { + "create_new": true, + "overwrite": false, + "symlink_follow": false, + "regular_file": true, + "single_link": true, + "owner_uid": "effective_uid", + "create_mode_octal": "0600", + "read_modes_octal": ["0400", "0600"] + }, + "verification": { + "expected_identity_required": true, + "derived_public_key_must_match": true, + "legacy_envelope_accepted": false, + "ordinary_run_provisions": false + }, + "backup": { + "state_backup_includes_envelope": false, + "state_backup_includes_wrapping_credential": false, + "state_backup_includes_plaintext_identity": 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_envelope; mod runtime_context; mod state_catalog; mod state_connection; @@ -35,6 +36,13 @@ pub use provider_contract::{ MycProviderOperationInput, MycProviderPublicIdentity, MycProviderRole, MycUntrustedProviderOutput, }; +pub use provider_envelope::{ + MYC_ENCRYPTED_IDENTITY_BACKUP_INCLUDED, MYC_ENCRYPTED_IDENTITY_ENVELOPE_CONTRACT_VERSION, + MYC_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES, MycCredentialResolutionProof, MycDecryptedIdentity, + MycEncryptedIdentityEnvelopeError, MycEncryptedIdentityEnvelopeErrorKind, + MycEncryptedIdentityProvisioningMaterial, MycWrappingCredential, open_myc_encrypted_identity, + provision_myc_encrypted_identity, +}; pub use radroots_runtime_paths::{ INSTANCE_ID_MAX_BYTES, InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, RadrootsPlatform, RadrootsServiceInstanceArtifacts, RuntimeContext, diff --git a/src/provider_contract.rs b/src/provider_contract.rs @@ -418,6 +418,13 @@ impl MycProviderBinding { } } + pub(crate) fn encrypted_envelope_path(&self) -> Option<&std::path::Path> { + match &self.location { + ProviderLocation::EncryptedFile { envelope_path, .. } => Some(envelope_path), + ProviderLocation::LocalSigner { .. } => None, + } + } + fn location_is_valid(&self) -> bool { match &self.location { ProviderLocation::EncryptedFile { diff --git a/src/provider_envelope.rs b/src/provider_envelope.rs @@ -0,0 +1,1380 @@ +//! Sealed encrypted-file identity provider boundary. + +use core::fmt; +use std::error::Error; +use std::path::Path; +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; + +use chacha20poly1305::aead::{Aead, KeyInit, Payload}; +use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce}; +use nostr::{Keys, SecretKey}; +use radroots_secrets::context::{ + EnvelopeContext, EnvelopePurpose, EnvelopeSubject, PayloadSchemaId, +}; +use radroots_secrets::envelope::{ + ENVELOPE_MAX_BYTES, ENVELOPE_VERSION, Nonce, SealMaterial, SealRequest, +}; +use radroots_secrets::error::Operation; +use radroots_secrets::id::{BackendKind, KeyVersion}; +use radroots_secrets::wrapping::{ + BoxFuture, SecretMaterial, UnwrapRequest, WrapRequest, WrappedSecret, +}; +use radroots_secrets::{EncryptedEnvelope, KeyWrapping, SecretId, SecretRef}; +use zeroize::Zeroizing; + +use crate::{MycProviderBinding, MycProviderKind, MycProviderPublicIdentity}; + +/// Exact Myc encrypted-identity envelope contract version. +pub const MYC_ENCRYPTED_IDENTITY_ENVELOPE_CONTRACT_VERSION: u32 = 1; +/// Hard encoded-envelope cap inherited from the source-locked secrets crate. +pub const MYC_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES: usize = ENVELOPE_MAX_BYTES; +/// Myc state backups never contain encrypted identity envelopes. +pub const MYC_ENCRYPTED_IDENTITY_BACKUP_INCLUDED: bool = false; + +const IDENTITY_SECRET_BYTES: usize = 32; +const WRAPPING_CREDENTIAL_BYTES: usize = 32; +const NONCE_BYTES: usize = 24; +const WRAPPED_KEY_MAGIC: [u8; 4] = *b"MYWK"; +const WRAPPED_KEY_VERSION: u8 = 1; +const WRAPPED_KEY_CIPHERTEXT_BYTES: usize = IDENTITY_SECRET_BYTES + 16; +const WRAPPED_KEY_BYTES: usize = + WRAPPED_KEY_MAGIC.len() + 1 + NONCE_BYTES + WRAPPED_KEY_CIPHERTEXT_BYTES; +const WRAPPING_AAD_DOMAIN: &[u8] = b"radroots.myc.wrapped_data_key.v1\0"; +const CONTEXT_PURPOSE: &str = "radroots.myc.encrypted_identity"; +const CONTEXT_SUBJECT_TYPE: &str = "provider_identity"; +const CONTEXT_PAYLOAD_SCHEMA: &str = "radroots.myc.identity_secret.v1"; + +/// Stable source-free encrypted-envelope failure classification. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycEncryptedIdentityEnvelopeErrorKind { + InvalidBinding, + InvalidCredential, + InvalidProvisioningMaterial, + InvalidPath, + MissingEnvelope, + AlreadyExists, + InsecureParent, + InsecureArtifact, + UnsupportedEnvelopeVersion, + MalformedEnvelope, + WrongCredential, + IdentityMismatch, + Io, + UnsupportedPlatform, +} + +impl MycEncryptedIdentityEnvelopeErrorKind { + /// Returns the stable machine-facing safe code. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::InvalidBinding => "provider_envelope_binding_invalid", + Self::InvalidCredential => "provider_envelope_credential_invalid", + Self::InvalidProvisioningMaterial => "provider_envelope_material_invalid", + Self::InvalidPath => "provider_envelope_path_invalid", + Self::MissingEnvelope => "provider_envelope_missing", + Self::AlreadyExists => "provider_envelope_already_exists", + Self::InsecureParent => "provider_envelope_parent_insecure", + Self::InsecureArtifact => "provider_envelope_artifact_insecure", + Self::UnsupportedEnvelopeVersion => "provider_envelope_version_unsupported", + Self::MalformedEnvelope => "provider_envelope_malformed", + Self::WrongCredential => "provider_envelope_credential_rejected", + Self::IdentityMismatch => "provider_envelope_identity_mismatch", + Self::Io => "provider_envelope_io_failed", + Self::UnsupportedPlatform => "provider_envelope_platform_unsupported", + } + } + + const fn message(self) -> &'static str { + match self { + Self::InvalidBinding => "encrypted identity provider binding is invalid", + Self::InvalidCredential => "encrypted identity credential is invalid", + Self::InvalidProvisioningMaterial => { + "encrypted identity provisioning material is invalid" + } + Self::InvalidPath => "encrypted identity path is invalid", + Self::MissingEnvelope => "encrypted identity envelope is missing", + Self::AlreadyExists => "encrypted identity envelope already exists", + Self::InsecureParent => "encrypted identity parent is insecure", + Self::InsecureArtifact => "encrypted identity artifact is insecure", + Self::UnsupportedEnvelopeVersion => { + "encrypted identity envelope version is unsupported" + } + Self::MalformedEnvelope => "encrypted identity envelope is malformed", + Self::WrongCredential => "encrypted identity credential was rejected", + Self::IdentityMismatch => "encrypted identity does not match configuration", + Self::Io => "encrypted identity storage failed", + Self::UnsupportedPlatform => "encrypted identity storage is unsupported", + } + } +} + +/// One source-free encrypted-envelope failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct MycEncryptedIdentityEnvelopeError { + kind: MycEncryptedIdentityEnvelopeErrorKind, +} + +impl MycEncryptedIdentityEnvelopeError { + /// Returns the stable failure kind. + #[must_use] + pub const fn kind(self) -> MycEncryptedIdentityEnvelopeErrorKind { + 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 MycEncryptedIdentityEnvelopeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycEncryptedIdentityEnvelopeError") + .field("kind", &self.kind) + .finish() + } +} + +impl fmt::Display for MycEncryptedIdentityEnvelopeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.kind.message()) + } +} + +impl Error for MycEncryptedIdentityEnvelopeError {} + +const fn envelope_error( + kind: MycEncryptedIdentityEnvelopeErrorKind, +) -> MycEncryptedIdentityEnvelopeError { + MycEncryptedIdentityEnvelopeError { kind } +} + +/// Sealed zeroizing wrapping credential resolved only by the governed credential boundary. +pub struct MycWrappingCredential(Zeroizing<[u8; WRAPPING_CREDENTIAL_BYTES]>); + +/// Non-forgeable proof that owns credential bytes admitted by the governed resolver. +pub struct MycCredentialResolutionProof { + credential: Zeroizing<[u8; WRAPPING_CREDENTIAL_BYTES]>, +} + +impl fmt::Debug for MycCredentialResolutionProof { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MycCredentialResolutionProof([sealed])") + } +} + +impl MycWrappingCredential { + /// Consumes exact credential bytes sealed inside governed resolution proof. + pub fn from_resolution( + proof: MycCredentialResolutionProof, + ) -> Result<Self, MycEncryptedIdentityEnvelopeError> { + if proof.credential.iter().all(|byte| *byte == 0) { + return Err(envelope_error( + MycEncryptedIdentityEnvelopeErrorKind::InvalidCredential, + )); + } + Ok(Self(proof.credential)) + } + + fn expose<T>(&self, use_credential: impl FnOnce(&[u8; 32]) -> T) -> T { + use_credential(&self.0) + } + + fn matches(&self, other: &[u8; 32]) -> bool { + self.expose(|credential| credential == other) + } +} + +impl fmt::Debug for MycWrappingCredential { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MycWrappingCredential([redacted])") + } +} + +/// Explicit single-owner material for one offline create-new provisioning operation. +pub struct MycEncryptedIdentityProvisioningMaterial { + identity_secret: Zeroizing<[u8; IDENTITY_SECRET_BYTES]>, + data_key: Zeroizing<[u8; IDENTITY_SECRET_BYTES]>, + envelope_nonce: [u8; NONCE_BYTES], + wrapping_nonce: [u8; NONCE_BYTES], +} + +impl MycEncryptedIdentityProvisioningMaterial { + /// Validates the identity secret and exact caller-supplied cryptographic material. + pub fn new( + identity_secret: [u8; IDENTITY_SECRET_BYTES], + data_key: [u8; IDENTITY_SECRET_BYTES], + envelope_nonce: [u8; NONCE_BYTES], + wrapping_nonce: [u8; NONCE_BYTES], + ) -> Result<Self, MycEncryptedIdentityEnvelopeError> { + let identity_secret = Zeroizing::new(identity_secret); + let data_key = Zeroizing::new(data_key); + if SecretKey::from_slice(&identity_secret[..]).is_err() + || data_key.iter().all(|byte| *byte == 0) + || envelope_nonce.iter().all(|byte| *byte == 0) + || wrapping_nonce.iter().all(|byte| *byte == 0) + { + return Err(envelope_error( + MycEncryptedIdentityEnvelopeErrorKind::InvalidProvisioningMaterial, + )); + } + Ok(Self { + identity_secret, + data_key, + envelope_nonce, + wrapping_nonce, + }) + } +} + +impl fmt::Debug for MycEncryptedIdentityProvisioningMaterial { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MycEncryptedIdentityProvisioningMaterial([redacted])") + } +} + +/// One verified zeroizing identity released only after envelope and public-key validation. +pub struct MycDecryptedIdentity { + secret: Zeroizing<[u8; IDENTITY_SECRET_BYTES]>, + public_identity: MycProviderPublicIdentity, +} + +impl MycDecryptedIdentity { + /// Returns the independently verified configured public identity. + #[must_use] + pub fn public_identity(&self) -> &MycProviderPublicIdentity { + &self.public_identity + } +} + +impl fmt::Debug for MycDecryptedIdentity { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycDecryptedIdentity") + .field("secret", &"[redacted]") + .field("secret_bytes", &self.secret.len()) + .field("public_identity", &"[redacted]") + .finish() + } +} + +/// Provisions one new encrypted identity envelope without overwriting any entry. +/// +/// A wrapping credential can be obtained only through the separately governed +/// credential-resolution boundary. Ordinary service startup never calls this +/// offline provisioning operation. +pub fn provision_myc_encrypted_identity( + binding: &MycProviderBinding, + credential: &MycWrappingCredential, + material: MycEncryptedIdentityProvisioningMaterial, +) -> Result<MycDecryptedIdentity, MycEncryptedIdentityEnvelopeError> { + ensure_supported_platform()?; + validate_encrypted_binding(binding)?; + validate_requested_path(envelope_path(binding)?)?; + let expected = binding.expected_identity(); + require_identity_match(&material.identity_secret, expected)?; + if credential.matches(&material.identity_secret) + || credential.matches(&material.data_key) + || material.identity_secret[..] == material.data_key[..] + { + return Err(invalid_material()); + } + + let context = envelope_context(binding)?; + let reference = envelope_reference(binding)?; + let plaintext = SecretMaterial::from_slice(&material.identity_secret[..]) + .map_err(|_| invalid_material())?; + let data_key = + SecretMaterial::from_slice(&material.data_key[..]).map_err(|_| invalid_material())?; + let sealer = CredentialSealer::new(credential, material.wrapping_nonce); + let envelope = futures_executor::block_on(EncryptedEnvelope::seal( + &sealer, + SealRequest::new( + reference, + context.clone(), + &plaintext, + SealMaterial::new(data_key, Nonce::new(material.envelope_nonce)), + ), + )) + .map_err(|_| invalid_material())?; + let encoded = envelope + .encode() + .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::MalformedEnvelope))?; + let verified = futures_executor::block_on(open_decoded_envelope( + binding, credential, envelope, &context, + ))?; + persist_create_new(envelope_path(binding)?, &encoded)?; + Ok(verified) +} + +/// Opens and verifies one existing encrypted identity envelope. +pub fn open_myc_encrypted_identity( + binding: &MycProviderBinding, + credential: &MycWrappingCredential, +) -> Result<MycDecryptedIdentity, MycEncryptedIdentityEnvelopeError> { + ensure_supported_platform()?; + validate_encrypted_binding(binding)?; + validate_requested_path(envelope_path(binding)?)?; + let encoded = read_existing(envelope_path(binding)?)?; + require_wire_version(&encoded)?; + let envelope = EncryptedEnvelope::decode(&encoded) + .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::MalformedEnvelope))?; + if envelope.version() != ENVELOPE_VERSION { + return Err(envelope_error( + MycEncryptedIdentityEnvelopeErrorKind::UnsupportedEnvelopeVersion, + )); + } + let context = envelope_context(binding)?; + futures_executor::block_on(open_decoded_envelope( + binding, credential, envelope, &context, + )) +} + +fn require_wire_version(encoded: &[u8]) -> Result<(), MycEncryptedIdentityEnvelopeError> { + if encoded.len() < 6 || &encoded[..4] != b"RRS1" { + return Err(envelope_error( + MycEncryptedIdentityEnvelopeErrorKind::MalformedEnvelope, + )); + } + let version = u16::from_be_bytes([encoded[4], encoded[5]]); + if version != ENVELOPE_VERSION { + return Err(envelope_error( + MycEncryptedIdentityEnvelopeErrorKind::UnsupportedEnvelopeVersion, + )); + } + Ok(()) +} + +async fn open_decoded_envelope( + binding: &MycProviderBinding, + credential: &MycWrappingCredential, + envelope: EncryptedEnvelope, + expected_context: &EnvelopeContext, +) -> Result<MycDecryptedIdentity, MycEncryptedIdentityEnvelopeError> { + if envelope.version() != ENVELOPE_VERSION { + return Err(envelope_error( + MycEncryptedIdentityEnvelopeErrorKind::UnsupportedEnvelopeVersion, + )); + } + let reference = envelope_reference(binding)?; + if !reference_matches(envelope.reference(), &reference) + || envelope.context() != Some(expected_context) + { + return Err(envelope_error( + MycEncryptedIdentityEnvelopeErrorKind::MalformedEnvelope, + )); + } + let opener = CredentialOpener::new(credential); + let plaintext = envelope + .open(&opener, expected_context) + .await + .map_err(|_| { + envelope_error(if opener.unwrap_succeeded() { + MycEncryptedIdentityEnvelopeErrorKind::MalformedEnvelope + } else { + MycEncryptedIdentityEnvelopeErrorKind::WrongCredential + }) + })?; + let mut secret = Zeroizing::new([0_u8; IDENTITY_SECRET_BYTES]); + let exact = plaintext.expose_secret(|bytes| { + if bytes.len() == IDENTITY_SECRET_BYTES { + secret.copy_from_slice(bytes); + true + } else { + false + } + }); + if !exact { + return Err(envelope_error( + MycEncryptedIdentityEnvelopeErrorKind::MalformedEnvelope, + )); + } + require_identity_match(&secret, binding.expected_identity())?; + Ok(MycDecryptedIdentity { + secret, + public_identity: binding.expected_identity().clone(), + }) +} + +fn validate_encrypted_binding( + binding: &MycProviderBinding, +) -> Result<(), MycEncryptedIdentityEnvelopeError> { + if binding.kind() != MycProviderKind::EncryptedFile + || binding.credential_reference().is_none() + || binding.encrypted_envelope_path().is_none() + { + return Err(envelope_error( + MycEncryptedIdentityEnvelopeErrorKind::InvalidBinding, + )); + } + Ok(()) +} + +fn envelope_path(binding: &MycProviderBinding) -> Result<&Path, MycEncryptedIdentityEnvelopeError> { + binding + .encrypted_envelope_path() + .ok_or_else(|| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::InvalidBinding)) +} + +fn envelope_reference( + binding: &MycProviderBinding, +) -> Result<SecretRef, MycEncryptedIdentityEnvelopeError> { + let credential = binding + .credential_reference() + .ok_or_else(|| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::InvalidBinding))?; + let id = SecretId::parse(credential.as_str()) + .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::InvalidCredential))?; + let key_version = KeyVersion::new(1) + .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::InvalidCredential))?; + Ok(SecretRef::new(id, BackendKind::External, key_version)) +} + +fn envelope_context( + binding: &MycProviderBinding, +) -> Result<EnvelopeContext, MycEncryptedIdentityEnvelopeError> { + let subject = format!( + "{}:{}", + binding.role().as_str(), + binding.expected_identity().as_hex() + ); + Ok(EnvelopeContext::new( + EnvelopePurpose::parse(CONTEXT_PURPOSE).map_err(|_| invalid_binding())?, + EnvelopeSubject::parse(CONTEXT_SUBJECT_TYPE, subject).map_err(|_| invalid_binding())?, + PayloadSchemaId::parse(CONTEXT_PAYLOAD_SCHEMA).map_err(|_| invalid_binding())?, + )) +} + +fn require_identity_match( + secret: &[u8; IDENTITY_SECRET_BYTES], + expected: &MycProviderPublicIdentity, +) -> Result<(), MycEncryptedIdentityEnvelopeError> { + let secret_key = SecretKey::from_slice(secret).map_err(|_| invalid_material())?; + let actual = Keys::new(secret_key).public_key().to_hex(); + if actual != expected.as_hex() { + return Err(envelope_error( + MycEncryptedIdentityEnvelopeErrorKind::IdentityMismatch, + )); + } + Ok(()) +} + +const fn invalid_binding() -> MycEncryptedIdentityEnvelopeError { + envelope_error(MycEncryptedIdentityEnvelopeErrorKind::InvalidBinding) +} + +const fn invalid_material() -> MycEncryptedIdentityEnvelopeError { + envelope_error(MycEncryptedIdentityEnvelopeErrorKind::InvalidProvisioningMaterial) +} + +fn reference_matches(actual: &SecretRef, expected: &SecretRef) -> bool { + actual.id().as_str() == expected.id().as_str() + && actual.backend() == expected.backend() + && actual.key_version() == expected.key_version() +} + +struct CredentialSealer<'a> { + credential: &'a MycWrappingCredential, + nonce: Mutex<Option<[u8; NONCE_BYTES]>>, +} + +impl<'a> CredentialSealer<'a> { + fn new(credential: &'a MycWrappingCredential, nonce: [u8; NONCE_BYTES]) -> Self { + Self { + credential, + nonce: Mutex::new(Some(nonce)), + } + } +} + +impl KeyWrapping for CredentialSealer<'_> { + fn wrap<'a>( + &'a self, + request: WrapRequest<'a>, + ) -> BoxFuture<'a, Result<WrappedSecret, radroots_secrets::Error>> { + Box::pin(async move { + validate_external_reference(request.reference(), Operation::Wrap)?; + let nonce = self + .nonce + .lock() + .map_err(|_| backend_failure(Operation::Wrap))? + .take() + .ok_or_else(|| backend_failure(Operation::Wrap))?; + let aad = wrapping_aad(request.reference(), request.context()); + let ciphertext = self.credential.expose(|credential| { + request.plaintext().expose_secret(|data_key| { + XChaCha20Poly1305::new(Key::from_slice(credential)).encrypt( + XNonce::from_slice(&nonce), + Payload { + msg: data_key, + aad: &aad, + }, + ) + }) + }); + let ciphertext = ciphertext.map_err(|_| backend_failure(Operation::Wrap))?; + let mut encoded = Vec::with_capacity(WRAPPED_KEY_BYTES); + encoded.extend_from_slice(&WRAPPED_KEY_MAGIC); + encoded.push(WRAPPED_KEY_VERSION); + encoded.extend_from_slice(&nonce); + encoded.extend_from_slice(&ciphertext); + WrappedSecret::from_bytes(encoded) + }) + } + + fn unwrap<'a>( + &'a self, + _request: UnwrapRequest<'a>, + ) -> BoxFuture<'a, Result<SecretMaterial, radroots_secrets::Error>> { + Box::pin(async { Err(backend_failure(Operation::Unwrap)) }) + } +} + +struct CredentialOpener<'a> { + credential: &'a MycWrappingCredential, + unwrap_succeeded: AtomicBool, +} + +impl<'a> CredentialOpener<'a> { + fn new(credential: &'a MycWrappingCredential) -> Self { + Self { + credential, + unwrap_succeeded: AtomicBool::new(false), + } + } + + fn unwrap_succeeded(&self) -> bool { + self.unwrap_succeeded.load(Ordering::Acquire) + } +} + +impl KeyWrapping for CredentialOpener<'_> { + fn wrap<'a>( + &'a self, + _request: WrapRequest<'a>, + ) -> BoxFuture<'a, Result<WrappedSecret, radroots_secrets::Error>> { + Box::pin(async { Err(backend_failure(Operation::Wrap)) }) + } + + fn unwrap<'a>( + &'a self, + request: UnwrapRequest<'a>, + ) -> BoxFuture<'a, Result<SecretMaterial, radroots_secrets::Error>> { + Box::pin(async move { + validate_external_reference(request.reference(), Operation::Unwrap)?; + let encoded = request.wrapped().as_bytes(); + if encoded.len() != WRAPPED_KEY_BYTES + || encoded[..WRAPPED_KEY_MAGIC.len()] != WRAPPED_KEY_MAGIC + || encoded[WRAPPED_KEY_MAGIC.len()] != WRAPPED_KEY_VERSION + { + return Err(backend_failure(Operation::Unwrap)); + } + let nonce_start = WRAPPED_KEY_MAGIC.len() + 1; + let nonce_end = nonce_start + NONCE_BYTES; + let aad = wrapping_aad(request.reference(), request.context()); + let plaintext = self.credential.expose(|credential| { + XChaCha20Poly1305::new(Key::from_slice(credential)).decrypt( + XNonce::from_slice(&encoded[nonce_start..nonce_end]), + Payload { + msg: &encoded[nonce_end..], + aad: &aad, + }, + ) + }); + let plaintext = + Zeroizing::new(plaintext.map_err(|_| backend_failure(Operation::Unwrap))?); + let material = SecretMaterial::from_slice(&plaintext)?; + self.unwrap_succeeded.store(true, Ordering::Release); + Ok(material) + }) + } +} + +fn wrapping_aad(reference: &SecretRef, context: &EnvelopeContext) -> Vec<u8> { + let id = reference.id().as_str().as_bytes(); + let mut aad = Vec::with_capacity(WRAPPING_AAD_DOMAIN.len() + 2 + id.len() + 4 + 32); + aad.extend_from_slice(WRAPPING_AAD_DOMAIN); + aad.extend_from_slice( + &u16::try_from(id.len()) + .unwrap_or_else(|_| unreachable!("validated secret reference fits u16")) + .to_be_bytes(), + ); + aad.extend_from_slice(id); + aad.extend_from_slice(&reference.key_version().get().to_be_bytes()); + aad.extend_from_slice(&context.authentication_digest()); + aad +} + +fn validate_external_reference( + reference: &SecretRef, + operation: Operation, +) -> Result<(), radroots_secrets::Error> { + if reference.backend() != BackendKind::External || reference.key_version().get() != 1 { + return Err(backend_failure(operation)); + } + Ok(()) +} + +const fn backend_failure(operation: Operation) -> radroots_secrets::Error { + radroots_secrets::Error::BackendFailure { + backend: BackendKind::External, + operation, + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +mod native { + use std::ffi::OsString; + use std::fs::File; + use std::io::{Read, Seek, SeekFrom, Write}; + use std::os::unix::ffi::OsStrExt; + use std::path::{Component, Path, PathBuf}; + + use rustix::fs::{AtFlags, FileType, Mode, OFlags, fchmod, fstat, open, openat, unlinkat}; + use rustix::process::geteuid; + + use super::{ + MYC_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES, MycEncryptedIdentityEnvelopeError, + MycEncryptedIdentityEnvelopeErrorKind, envelope_error, + }; + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + struct Identity { + device: u64, + inode: u64, + } + + struct ArtifactPath { + parent_path: PathBuf, + name: OsString, + } + + impl ArtifactPath { + fn parse(path: &Path) -> Result<Self, MycEncryptedIdentityEnvelopeError> { + if !path.is_absolute() + || path.as_os_str().as_bytes().len() > 4_096 + || path.components().any(|component| { + !matches!(component, Component::RootDir | Component::Normal(_)) + }) + { + return Err(envelope_error( + MycEncryptedIdentityEnvelopeErrorKind::InvalidPath, + )); + } + let name = match path.components().next_back() { + Some(Component::Normal(name)) if !name.as_bytes().is_empty() => name.to_os_string(), + _ => { + return Err(envelope_error( + MycEncryptedIdentityEnvelopeErrorKind::InvalidPath, + )); + } + }; + let parent_path = path + .parent() + .filter(|parent| parent.is_absolute()) + .ok_or_else(|| { + envelope_error(MycEncryptedIdentityEnvelopeErrorKind::InvalidPath) + })?; + Ok(Self { + parent_path: parent_path.to_path_buf(), + name, + }) + } + } + + pub(super) fn validate_requested_path( + path: &Path, + ) -> Result<(), MycEncryptedIdentityEnvelopeError> { + ArtifactPath::parse(path).map(|_| ()) + } + + pub(super) fn persist_create_new( + path: &Path, + encoded: &[u8], + ) -> Result<(), MycEncryptedIdentityEnvelopeError> { + if encoded.is_empty() || encoded.len() > MYC_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES { + return Err(envelope_error( + MycEncryptedIdentityEnvelopeErrorKind::MalformedEnvelope, + )); + } + let path = ArtifactPath::parse(path)?; + let parent = open_parent(&path.parent_path, true)?; + let parent_identity = directory_identity(&parent, true)?; + let descriptor = openat( + &parent, + &path.name, + OFlags::WRONLY + | OFlags::CREATE + | OFlags::EXCL + | OFlags::NOFOLLOW + | OFlags::CLOEXEC + | OFlags::NONBLOCK, + Mode::RUSR | Mode::WUSR, + ) + .map_err(|source| { + envelope_error(if source == rustix::io::Errno::EXIST { + MycEncryptedIdentityEnvelopeErrorKind::AlreadyExists + } else { + MycEncryptedIdentityEnvelopeErrorKind::Io + }) + })?; + let mut file = File::from(descriptor); + let identity = owned_file_identity(&file)?; + let result = (|| { + fchmod(&file, Mode::RUSR | Mode::WUSR) + .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::Io))?; + file.write_all(encoded) + .and_then(|()| file.sync_all()) + .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::Io))?; + file_identity(&file, Some(encoded.len()))?; + validate_current_binding( + &path, + &parent, + parent_identity, + &file, + identity, + encoded.len(), + )?; + parent + .sync_all() + .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::Io))?; + validate_current_binding( + &path, + &parent, + parent_identity, + &file, + identity, + encoded.len(), + ) + })(); + if result.is_err() { + cleanup_owned(&parent, &path.name, identity); + } + result + } + + pub(super) fn read_existing(path: &Path) -> Result<Vec<u8>, MycEncryptedIdentityEnvelopeError> { + let path = ArtifactPath::parse(path)?; + let parent = open_parent(&path.parent_path, false)?; + let parent_identity = directory_identity(&parent, false)?; + let descriptor = openat( + &parent, + &path.name, + OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK, + Mode::empty(), + ) + .map_err(|source| { + envelope_error(if source == rustix::io::Errno::NOENT { + MycEncryptedIdentityEnvelopeErrorKind::MissingEnvelope + } else if source == rustix::io::Errno::LOOP { + MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact + } else { + MycEncryptedIdentityEnvelopeErrorKind::Io + }) + })?; + let mut file = File::from(descriptor); + let status = fstat(&file) + .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact))?; + let length = validate_file_status(&status, None)?; + let identity = status_identity( + &status, + MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact, + )?; + file.seek(SeekFrom::Start(0)) + .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::Io))?; + let mut encoded = Vec::with_capacity(length); + std::io::Read::by_ref(&mut file) + .take(u64::try_from(length).unwrap_or(u64::MAX).saturating_add(1)) + .read_to_end(&mut encoded) + .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::Io))?; + if encoded.len() != length { + return Err(envelope_error( + MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact, + )); + } + validate_current_binding(&path, &parent, parent_identity, &file, identity, length)?; + Ok(encoded) + } + + fn open_parent(path: &Path, writable: bool) -> Result<File, MycEncryptedIdentityEnvelopeError> { + let mut components = path.components(); + if !matches!(components.next(), Some(Component::RootDir)) { + return Err(envelope_error( + MycEncryptedIdentityEnvelopeErrorKind::InvalidPath, + )); + } + let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC; + let mut parent = + File::from(open(Path::new("/"), flags, Mode::empty()).map_err(|_| { + envelope_error(MycEncryptedIdentityEnvelopeErrorKind::InsecureParent) + })?); + for component in components { + let Component::Normal(name) = component else { + return Err(envelope_error( + MycEncryptedIdentityEnvelopeErrorKind::InvalidPath, + )); + }; + parent = File::from(openat(&parent, name, flags, Mode::empty()).map_err(|_| { + envelope_error(MycEncryptedIdentityEnvelopeErrorKind::InsecureParent) + })?); + } + directory_identity(&parent, writable)?; + Ok(parent) + } + + fn directory_identity( + directory: &File, + writable: bool, + ) -> Result<Identity, MycEncryptedIdentityEnvelopeError> { + let status = fstat(directory) + .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::InsecureParent))?; + let mode = native_mode(status.st_mode); + let allowed_mode = if writable { + mode & 0o777 == 0o700 + } else { + matches!(mode & 0o777, 0o500 | 0o700) + }; + if !FileType::from_raw_mode(status.st_mode).is_dir() + || status.st_uid != geteuid().as_raw() + || !allowed_mode + { + return Err(envelope_error( + MycEncryptedIdentityEnvelopeErrorKind::InsecureParent, + )); + } + status_identity( + &status, + MycEncryptedIdentityEnvelopeErrorKind::InsecureParent, + ) + } + + fn file_identity( + file: &File, + expected_length: Option<usize>, + ) -> Result<Identity, MycEncryptedIdentityEnvelopeError> { + let status = fstat(file) + .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact))?; + validate_file_status(&status, expected_length)?; + status_identity( + &status, + MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact, + ) + } + + fn owned_file_identity(file: &File) -> Result<Identity, MycEncryptedIdentityEnvelopeError> { + let status = fstat(file) + .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact))?; + let mode = native_mode(status.st_mode) & 0o777; + let length = usize::try_from(status.st_size) + .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact))?; + if !FileType::from_raw_mode(status.st_mode).is_file() + || native_link_count(status.st_nlink) != 1 + || status.st_uid != geteuid().as_raw() + || !matches!(mode, 0o400 | 0o600) + || length > MYC_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES + { + return Err(envelope_error( + MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact, + )); + } + status_identity( + &status, + MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact, + ) + } + + fn validate_file_status( + status: &rustix::fs::Stat, + expected_length: Option<usize>, + ) -> Result<usize, MycEncryptedIdentityEnvelopeError> { + let mode = native_mode(status.st_mode) & 0o777; + let length = usize::try_from(status.st_size) + .map_err(|_| envelope_error(MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact))?; + if !FileType::from_raw_mode(status.st_mode).is_file() + || native_link_count(status.st_nlink) != 1 + || status.st_uid != geteuid().as_raw() + || !matches!(mode, 0o400 | 0o600) + || length == 0 + || length > MYC_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES + || expected_length.is_some_and(|expected| expected != length) + { + return Err(envelope_error( + MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact, + )); + } + Ok(length) + } + + fn validate_current_binding( + path: &ArtifactPath, + held_parent: &File, + expected_parent: Identity, + held_file: &File, + expected_file: Identity, + expected_length: usize, + ) -> Result<(), MycEncryptedIdentityEnvelopeError> { + let current_parent = open_parent(&path.parent_path, false)?; + if directory_identity(held_parent, false)? != expected_parent + || directory_identity(&current_parent, false)? != expected_parent + { + return Err(envelope_error( + MycEncryptedIdentityEnvelopeErrorKind::InsecureParent, + )); + } + let current_file = File::from( + openat( + &current_parent, + &path.name, + OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK, + Mode::empty(), + ) + .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 + { + return Err(envelope_error( + MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact, + )); + } + Ok(()) + } + + fn cleanup_owned(parent: &File, name: &std::ffi::OsStr, expected: Identity) { + let Ok(current) = openat( + parent, + name, + OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK, + Mode::empty(), + ) else { + return; + }; + let current = File::from(current); + if owned_file_identity(&current) == Ok(expected) + && unlinkat(parent, name, AtFlags::empty()).is_ok() + { + let _ = parent.sync_all(); + } + } + + fn status_identity( + status: &rustix::fs::Stat, + invalid_kind: MycEncryptedIdentityEnvelopeErrorKind, + ) -> Result<Identity, MycEncryptedIdentityEnvelopeError> { + Ok(Identity { + device: native_device(status.st_dev).map_err(|_| envelope_error(invalid_kind))?, + inode: status.st_ino, + }) + } + + fn native_mode<T: Into<u32>>(raw: T) -> u32 { + raw.into() + } + + fn native_link_count<T: Into<u64>>(raw: T) -> u64 { + raw.into() + } + + fn native_device<T: TryInto<u64>>(raw: T) -> Result<u64, T::Error> { + raw.try_into() + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +use native::{persist_create_new, read_existing, validate_requested_path}; + +#[cfg(any(target_os = "linux", target_os = "macos"))] +const fn ensure_supported_platform() -> Result<(), MycEncryptedIdentityEnvelopeError> { + Ok(()) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn validate_requested_path(_path: &Path) -> Result<(), MycEncryptedIdentityEnvelopeError> { + Err(envelope_error( + MycEncryptedIdentityEnvelopeErrorKind::UnsupportedPlatform, + )) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +const fn ensure_supported_platform() -> Result<(), MycEncryptedIdentityEnvelopeError> { + Err(envelope_error( + MycEncryptedIdentityEnvelopeErrorKind::UnsupportedPlatform, + )) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn persist_create_new( + _path: &Path, + _encoded: &[u8], +) -> Result<(), MycEncryptedIdentityEnvelopeError> { + Err(envelope_error( + MycEncryptedIdentityEnvelopeErrorKind::UnsupportedPlatform, + )) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn read_existing(_path: &Path) -> Result<Vec<u8>, MycEncryptedIdentityEnvelopeError> { + Err(envelope_error( + MycEncryptedIdentityEnvelopeErrorKind::UnsupportedPlatform, + )) +} + +#[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 sha2::{Digest, Sha256}; + + #[cfg(any(target_os = "linux", target_os = "macos"))] + use crate::{MycConfigProfile, MycProviderRole, parse_myc_config_v1}; + + use super::*; + + #[cfg(any(target_os = "linux", target_os = "macos"))] + 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 identity_secret() -> [u8; 32] { + let mut candidate = bytes("radroots.myc.test-only.identity-secret.v1"); + while SecretKey::from_slice(&candidate).is_err() { + candidate = Sha256::digest(candidate).into(); + } + candidate + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn expected_identity() -> String { + Keys::new(SecretKey::from_slice(&identity_secret()).expect("test key")) + .public_key() + .to_hex() + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn binding(path: &Path) -> MycProviderBinding { + let source = CONFIG + .replace( + "/var/lib/radroots/services/myc/primary/secrets/transport.identity.ncrypt", + path.to_str().expect("UTF-8 test path"), + ) + .replace( + "4444444444444444444444444444444444444444444444444444444444444444", + &expected_identity(), + ); + parse_myc_config_v1(source.as_bytes(), MycConfigProfile::Production) + .expect("test config") + .provider_contract() + .binding(MycProviderRole::Transport) + .expect("transport binding") + .clone() + } + + fn credential(label: &str) -> MycWrappingCredential { + MycWrappingCredential::from_resolution(MycCredentialResolutionProof { + credential: Zeroizing::new(bytes(label)), + }) + .expect("test credential") + } + + fn material_for(identity: [u8; 32]) -> MycEncryptedIdentityProvisioningMaterial { + MycEncryptedIdentityProvisioningMaterial::new( + identity, + bytes("radroots.myc.test-only.data-key.v1"), + [7; 24], + [9; 24], + ) + .expect("test material") + } + + fn material() -> MycEncryptedIdentityProvisioningMaterial { + material_for(identity_secret()) + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn different_identity_secret() -> [u8; 32] { + let mut candidate = bytes("radroots.myc.test-only.different-identity-secret.v1"); + while SecretKey::from_slice(&candidate).is_err() { + candidate = Sha256::digest(candidate).into(); + } + candidate + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn secure_directory() -> tempfile::TempDir { + let directory = tempfile::tempdir().expect("temporary directory"); + fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)) + .expect("secure mode"); + directory + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn create_new_round_trip_binds_context_identity_and_permissions() { + let directory = secure_directory(); + let path = directory.path().join("transport.identity.ncrypt"); + let binding = binding(&path); + let credential = credential("radroots.myc.test-only.wrapping.v1"); + let provisioned = + provision_myc_encrypted_identity(&binding, &credential, material()).expect("provision"); + assert_eq!(provisioned.public_identity().as_hex(), expected_identity()); + assert_eq!( + fs::metadata(&path).expect("metadata").permissions().mode() & 0o777, + 0o600 + ); + let reopened = open_myc_encrypted_identity(&binding, &credential).expect("open"); + assert_eq!(reopened.public_identity().as_hex(), expected_identity()); + let names = fs::read_dir(directory.path()) + .expect("inventory") + .map(|entry| entry.expect("entry").file_name()) + .collect::<Vec<_>>(); + assert_eq!( + names, + vec![std::ffi::OsString::from("transport.identity.ncrypt")] + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn collisions_wrong_credentials_and_identity_mismatch_fail_safely() { + let directory = secure_directory(); + let path = directory.path().join("transport.identity.ncrypt"); + let binding = binding(&path); + let wrong_credential = credential("radroots.myc.test-only.wrong-wrapping.v1"); + let credential = credential("radroots.myc.test-only.wrapping.v1"); + assert_eq!( + provision_myc_encrypted_identity( + &binding, + &credential, + material_for(different_identity_secret()), + ) + .expect_err("wrong identity") + .kind(), + MycEncryptedIdentityEnvelopeErrorKind::IdentityMismatch + ); + assert!(!path.exists()); + provision_myc_encrypted_identity(&binding, &credential, material()).expect("provision"); + let before = fs::read(&path).expect("before"); + assert_eq!( + provision_myc_encrypted_identity(&binding, &credential, material()) + .expect_err("collision") + .kind(), + MycEncryptedIdentityEnvelopeErrorKind::AlreadyExists + ); + assert_eq!(fs::read(&path).expect("after"), before); + assert_eq!( + open_myc_encrypted_identity(&binding, &wrong_credential) + .expect_err("wrong credential") + .kind(), + MycEncryptedIdentityEnvelopeErrorKind::WrongCredential + ); + + let wrong_expected = CONFIG.replace( + "/var/lib/radroots/services/myc/primary/secrets/transport.identity.ncrypt", + path.to_str().expect("UTF-8 test path"), + ); + let wrong_binding = + parse_myc_config_v1(wrong_expected.as_bytes(), MycConfigProfile::Production) + .expect("wrong expected config") + .provider_contract() + .binding(MycProviderRole::Transport) + .expect("transport") + .clone(); + assert_eq!( + open_myc_encrypted_identity(&wrong_binding, &credential) + .expect_err("context mismatch") + .kind(), + MycEncryptedIdentityEnvelopeErrorKind::MalformedEnvelope + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn malformed_oversized_and_insecure_artifacts_fail_before_secret_release() { + let directory = secure_directory(); + let path = directory.path().join("transport.identity.ncrypt"); + let binding = binding(&path); + let credential = credential("radroots.myc.test-only.wrapping.v1"); + assert_eq!( + open_myc_encrypted_identity(&binding, &credential) + .expect_err("missing") + .kind(), + MycEncryptedIdentityEnvelopeErrorKind::MissingEnvelope + ); + provision_myc_encrypted_identity(&binding, &credential, material()).expect("provision"); + let valid = fs::read(&path).expect("valid envelope"); + let second_link = directory.path().join("second-link.ncrypt"); + fs::hard_link(&path, &second_link).expect("hard link"); + assert_eq!( + open_myc_encrypted_identity(&binding, &credential) + .expect_err("multiple links") + .kind(), + MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact + ); + fs::remove_file(&second_link).expect("remove hard link"); + fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o500)) + .expect("read-only parent mode"); + open_myc_encrypted_identity(&binding, &credential).expect("0500 parent is owner-only"); + fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)) + .expect("restore parent mode"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o400)).expect("read-only mode"); + open_myc_encrypted_identity(&binding, &credential).expect("0400 is owner-only"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("writable mode"); + let mut tampered = valid.clone(); + *tampered.last_mut().expect("ciphertext byte") ^= 1; + fs::write(&path, &tampered).expect("tamper ciphertext"); + assert_eq!( + open_myc_encrypted_identity(&binding, &credential) + .expect_err("tampered ciphertext") + .kind(), + MycEncryptedIdentityEnvelopeErrorKind::MalformedEnvelope + ); + let mut unsupported = valid; + unsupported[4..6].copy_from_slice(&1_u16.to_be_bytes()); + fs::write(&path, &unsupported).expect("unsupported version"); + assert_eq!( + open_myc_encrypted_identity(&binding, &credential) + .expect_err("unsupported version") + .kind(), + MycEncryptedIdentityEnvelopeErrorKind::UnsupportedEnvelopeVersion + ); + fs::remove_file(&path).expect("remove version vector"); + fs::write(&path, b"not-an-envelope").expect("malformed"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("mode"); + assert_eq!( + open_myc_encrypted_identity(&binding, &credential) + .expect_err("malformed") + .kind(), + MycEncryptedIdentityEnvelopeErrorKind::MalformedEnvelope + ); + fs::remove_file(&path).expect("remove malformed"); + fs::write( + &path, + vec![0_u8; MYC_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES + 1], + ) + .expect("oversized"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("mode"); + assert_eq!( + open_myc_encrypted_identity(&binding, &credential) + .expect_err("oversized") + .kind(), + MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact + ); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("insecure mode"); + assert_eq!( + open_myc_encrypted_identity(&binding, &credential) + .expect_err("permissions") + .kind(), + MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn symlink_and_insecure_parent_are_rejected_without_mutation() { + let directory = secure_directory(); + let target = directory.path().join("target"); + fs::write(&target, b"preserve").expect("target"); + fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).expect("target mode"); + let path = directory.path().join("transport.identity.ncrypt"); + symlink(&target, &path).expect("symlink"); + let provider_binding = binding(&path); + let credential = credential("radroots.myc.test-only.wrapping.v1"); + assert_eq!( + open_myc_encrypted_identity(&provider_binding, &credential) + .expect_err("symlink") + .kind(), + MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact + ); + assert_eq!(fs::read(&target).expect("preserved"), b"preserve"); + fs::remove_file(&path).expect("remove symlink"); + fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o755)) + .expect("insecure parent"); + assert_eq!( + provision_myc_encrypted_identity(&provider_binding, &credential, material()) + .expect_err("insecure parent") + .kind(), + MycEncryptedIdentityEnvelopeErrorKind::InsecureParent + ); + assert!(!path.exists()); + + fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)) + .expect("restore parent mode"); + let real_parent = directory.path().join("real-parent"); + fs::create_dir(&real_parent).expect("real parent"); + fs::set_permissions(&real_parent, fs::Permissions::from_mode(0o700)) + .expect("real parent mode"); + let linked_parent = directory.path().join("linked-parent"); + symlink(&real_parent, &linked_parent).expect("parent symlink"); + let linked_path = linked_parent.join("transport.identity.ncrypt"); + let linked_binding = binding(&linked_path); + assert_eq!( + provision_myc_encrypted_identity(&linked_binding, &credential, material()) + .expect_err("symlinked ancestor") + .kind(), + MycEncryptedIdentityEnvelopeErrorKind::InsecureParent + ); + assert!(!real_parent.join("transport.identity.ncrypt").exists()); + } + + #[test] + fn protected_models_and_errors_are_redacted_and_source_free() { + let proof = MycCredentialResolutionProof { + credential: Zeroizing::new(bytes("radroots.myc.test-only.wrapping.v1")), + }; + assert_eq!( + format!("{proof:?}"), + "MycCredentialResolutionProof([sealed])" + ); + let credential = MycWrappingCredential::from_resolution(proof).expect("test credential"); + let material = material(); + assert_eq!( + format!("{credential:?}"), + "MycWrappingCredential([redacted])" + ); + assert_eq!( + format!("{material:?}"), + "MycEncryptedIdentityProvisioningMaterial([redacted])" + ); + for kind in [ + MycEncryptedIdentityEnvelopeErrorKind::InvalidBinding, + MycEncryptedIdentityEnvelopeErrorKind::InvalidCredential, + MycEncryptedIdentityEnvelopeErrorKind::InvalidProvisioningMaterial, + MycEncryptedIdentityEnvelopeErrorKind::InvalidPath, + MycEncryptedIdentityEnvelopeErrorKind::MissingEnvelope, + MycEncryptedIdentityEnvelopeErrorKind::AlreadyExists, + MycEncryptedIdentityEnvelopeErrorKind::InsecureParent, + MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact, + MycEncryptedIdentityEnvelopeErrorKind::UnsupportedEnvelopeVersion, + MycEncryptedIdentityEnvelopeErrorKind::MalformedEnvelope, + MycEncryptedIdentityEnvelopeErrorKind::WrongCredential, + MycEncryptedIdentityEnvelopeErrorKind::IdentityMismatch, + MycEncryptedIdentityEnvelopeErrorKind::Io, + MycEncryptedIdentityEnvelopeErrorKind::UnsupportedPlatform, + ] { + let error = envelope_error(kind); + assert!(!error.code().is_empty()); + assert!(!error.to_string().contains("test-only")); + assert!(error.source().is_none()); + } + assert_eq!( + MycWrappingCredential::from_resolution(MycCredentialResolutionProof { + credential: Zeroizing::new([0; 32]), + }) + .expect_err("zero credential") + .kind(), + MycEncryptedIdentityEnvelopeErrorKind::InvalidCredential + ); + assert!( + MycEncryptedIdentityProvisioningMaterial::new([0; 32], [1; 32], [1; 24], [2; 24]) + .is_err() + ); + } +} diff --git a/tests/services_hardening_encrypted_envelope.rs b/tests/services_hardening_encrypted_envelope.rs @@ -0,0 +1,150 @@ +use serde_json::json; + +const CONTRACT: &str = + include_str!("../contracts/services_hardening/encrypted_identity_envelope.v1.json"); +const LIB_SOURCE: &str = include_str!("../src/lib.rs"); +const ENVELOPE_SOURCE: &str = include_str!("../src/provider_envelope.rs"); +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_exact_envelope_and_backup_boundary() { + let actual: serde_json::Value = serde_json::from_str(CONTRACT).expect("contract JSON"); + assert_eq!( + actual, + json!({ + "schema": "radroots.myc.encrypted-identity-envelope", + "schema_version": 1, + "contract_version": 1, + "radroots_secrets_envelope_version": 2, + "encoded_max_bytes": 262144, + "identity_secret_bytes": 32, + "wrapping_credential_bytes": 32, + "provisioning_entropy": { + "data_key_bytes": 32, + "envelope_nonce_bytes": 24, + "wrapping_nonce_bytes": 24, + "caller_supplied": true + }, + "authenticated_context": { + "purpose": "radroots.myc.encrypted_identity", + "subject_type": "provider_identity", + "subject_value": "<role>:<expected_public_key>", + "payload_schema": "radroots.myc.identity_secret.v1", + "credential_reference_bound": true + }, + "artifact": { + "create_new": true, + "overwrite": false, + "symlink_follow": false, + "regular_file": true, + "single_link": true, + "owner_uid": "effective_uid", + "create_mode_octal": "0600", + "read_modes_octal": ["0400", "0600"] + }, + "verification": { + "expected_identity_required": true, + "derived_public_key_must_match": true, + "legacy_envelope_accepted": false, + "ordinary_run_provisions": false + }, + "backup": { + "state_backup_includes_envelope": false, + "state_backup_includes_wrapping_credential": false, + "state_backup_includes_plaintext_identity": false + } + }) + ); +} + +#[test] +fn implementation_uses_the_shared_envelope_and_keeps_secret_resolution_sealed() { + for required in [ + "EncryptedEnvelope::seal(", + "EncryptedEnvelope::decode(", + ".open(&opener, expected_context)", + "binding.role().as_str()", + "OFlags::CREATE", + "OFlags::EXCL", + "OFlags::NOFOLLOW", + "Mode::RUSR | Mode::WUSR", + "derived_public_key_must_match", + ] { + assert!( + ENVELOPE_SOURCE.contains(required) || CONTRACT.contains(required), + "missing envelope boundary {required}" + ); + } + for forbidden in [ + "pub fn from_resolved_bytes", + "pub fn from_bytes", + "std::env::", + "process::Command", + "keyring::", + "LEGACY_ENVELOPE_VERSION", + "open_legacy_v1", + "reseal_legacy_v1", + ".key\"", + "create_dir_all", + ] { + assert!( + !ENVELOPE_SOURCE.contains(forbidden), + "forbidden envelope authority {forbidden}" + ); + } + assert!(LIB_SOURCE.contains("mod provider_envelope;")); + assert!(!LIB_SOURCE.contains("pub mod provider_envelope")); +} + +#[test] +fn service_state_backup_remains_a_single_database_without_provider_material() { + assert!(HOST_SOURCE.contains(".capture_online_backup(staging_directory, created_at)")); + assert!(MAINTENANCE_SOURCE.contains("verify_backup_bundle(")); + for source in [HOST_SOURCE, MAINTENANCE_SOURCE] { + for forbidden in [ + "provider_envelope", + "identity.ncrypt", + "MycWrappingCredential", + "MycDecryptedIdentity", + ] { + assert!( + !source.contains(forbidden), + "state backup must exclude {forbidden}" + ); + } + } +} + +#[test] +fn public_error_and_protected_types_are_dependency_and_path_free() { + for required in [ + "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), + "missing boundary {required}" + ); + } + for forbidden in [ + "pub path:", + "pub source:", + "pub credential:", + "pub secret:", + "pub data_key:", + "pub envelope_nonce:", + "pub wrapping_nonce:", + "pub fn expose_secret", + "pub fn encrypted_envelope_path", + ] { + assert!(!ENVELOPE_SOURCE.contains(forbidden)); + } +}