myc

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

commit a70da66e3a4fc606313021c8e9964a9a8e4985eb
parent 069ccb703a03a042ea13170e8076ca06c8cfa85d
Author: triesap <tyson@radroots.org>
Date:   Fri, 21 Aug 2026 20:03:16 +0000

provider: freeze signer contract

Diffstat:
MAGENTS.md | 7+++++++
MREADME | 11+++++++++++
Acontracts/services_hardening/provider_contract.v1.json | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/config_v1.rs | 13+++++++++++++
Msrc/lib.rs | 12++++++++++++
Asrc/provider_contract.rs | 1170+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/services_hardening_provider_contract.rs | 313+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 1598 insertions(+), 0 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -36,6 +36,13 @@ Service implementation must use its exact routes, operation IDs, model fields, doctor checks, and shared host/exit references; prototype CLI or HTTP behavior is not authority to reinterpret that contract. +- The exact signer-provider inventory and resource boundary is + `contracts/services_hardening/provider_contract.v1.json`. Keep its role, + provider, capability, call-identity, deadline, limit, credential-reference, + cancellation, and no-publication facts synchronized with the sealed Rust + models. Provider wire encoding and result verification may refine only the + later checkpoints that own those boundaries; they may not widen this + contract. - 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 @@ -74,6 +74,17 @@ the only writer authority is its retained `state.lock`; there is no backend selection, compatibility reader, prototype importer, or secondary migration engine. +The side-effect-free signer-provider contract admits exactly `encrypted_file` +and `local_signer` assignments for the explicit transport, user, and optional +discovery roles. It freezes the seven-operation capability vocabulary, exact +role requirements, stable provider-instance/operation/correlation identities, +absolute deadlines, bounded semantic input and untrusted output, local-signer +resource limits, and logical credential references. Construction derives from +the already admitted v1 configuration, exposes no provider path or secret in +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. + 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/provider_contract.v1.json b/contracts/services_hardening/provider_contract.v1.json @@ -0,0 +1,72 @@ +{ + "schema": "radroots.myc.provider-contract", + "schema_version": 1, + "contract_version": 1, + "provider_kinds": ["encrypted_file", "local_signer"], + "capabilities": [ + "describe", + "public_identity", + "sign_event", + "nip04_encrypt", + "nip04_decrypt", + "nip44_encrypt", + "nip44_decrypt" + ], + "roles": [ + { + "role": "transport", + "provider_instance": "transport", + "required_capabilities": [ + "describe", + "public_identity", + "nip04_encrypt", + "nip04_decrypt", + "nip44_encrypt", + "nip44_decrypt" + ] + }, + { + "role": "user", + "provider_instance": "user", + "required_capabilities": [ + "describe", + "public_identity", + "sign_event", + "nip04_encrypt", + "nip04_decrypt", + "nip44_encrypt", + "nip44_decrypt" + ] + }, + { + "role": "discovery", + "provider_instance": "discovery", + "required_capabilities": ["describe", "public_identity", "sign_event"] + } + ], + "operation_binding": { + "operation_id_bytes": 32, + "correlation_id_bytes": 32, + "absolute_deadline_unix_ms": { "minimum": 1, "maximum": 9223372036854775807 }, + "nip44_versions": [2], + "semantic_input_max_bytes": 262144, + "untrusted_output_max_bytes": 1048576 + }, + "local_signer_limits": { + "request_deadline_ms": { "minimum": 1, "maximum": 30000 }, + "request_max_bytes": { "minimum": 1, "maximum": 65536 }, + "response_max_bytes": { "minimum": 1, "maximum": 1048576 }, + "concurrency": { "minimum": 1, "maximum": 64 } + }, + "credential_reference": { + "maximum_utf8_bytes": 128, + "shared_type": "ServiceCredentialArtifactName", + "material_in_configuration": false + }, + "execution": { + "database_transaction_held": false, + "provider_result_trusted": false, + "cancellation_proves_no_effect": false, + "signing_is_publication": false + } +} diff --git a/src/config_v1.rs b/src/config_v1.rs @@ -10,6 +10,8 @@ use serde::Serialize; use serde_json::{Map, Value, json}; use url::Url; +use crate::provider_contract::MycProviderContract; + const CONFIG_SCHEMA: &str = include_str!("../contracts/services_hardening/config.v1.schema.json"); /// Exact schema identity for the production Myc configuration document. @@ -152,6 +154,7 @@ pub struct MycConfigDocumentV1 { profile: MycConfigProfile, normalized: Value, effective: MycEffectiveConfigV1, + provider_contract: MycProviderContract, } impl MycConfigDocumentV1 { @@ -188,6 +191,12 @@ impl MycConfigDocumentV1 { .map_or(0, Vec::len) } + /// Returns the immutable provider assignments derived from this document. + #[must_use] + pub const fn provider_contract(&self) -> &MycProviderContract { + &self.provider_contract + } + pub(crate) const fn normalized(&self) -> &Value { &self.normalized } @@ -201,6 +210,7 @@ impl fmt::Debug for MycConfigDocumentV1 { .field("schema_version", &MYC_CONFIG_SCHEMA_VERSION) .field("profile", &self.profile) .field("effective", &self.effective) + .field("provider_contract", &self.provider_contract) .finish() } } @@ -234,10 +244,13 @@ pub fn parse_myc_config_v1( } validate_relationships(&normalized, profile)?; let effective = build_effective(&normalized, &original)?; + let provider_contract = MycProviderContract::from_normalized(&normalized) + .map_err(|_| error(MycConfigV1ErrorKind::InvalidRelationship))?; Ok(MycConfigDocumentV1 { profile, normalized, effective, + provider_contract, }) } diff --git a/src/lib.rs b/src/lib.rs @@ -2,6 +2,7 @@ mod cli_v1; mod config_v1; +mod provider_contract; mod runtime_context; mod state_catalog; mod state_connection; @@ -23,6 +24,17 @@ pub use config_v1::{ MycConfigDocumentV1, MycConfigProfile, MycConfigV1Error, MycConfigV1ErrorKind, MycConfigValueSource, MycEffectiveConfigV1, parse_myc_config_v1, }; +pub use provider_contract::{ + MYC_PROVIDER_CONCURRENCY_MAX, MYC_PROVIDER_CONTRACT_VERSION, MYC_PROVIDER_INPUT_MAX_BYTES, + MYC_PROVIDER_OUTPUT_MAX_BYTES, MYC_PROVIDER_REQUEST_DEADLINE_MAX_MS, + MYC_PROVIDER_REQUEST_MAX_BYTES, MYC_PROVIDER_RESPONSE_MAX_BYTES, MycLocalSignerLimits, + MycProviderBinding, MycProviderCapability, MycProviderCapabilitySet, MycProviderContract, + MycProviderContractError, MycProviderContractErrorKind, MycProviderCorrelationId, + MycProviderCredentialReference, MycProviderDeadlineUnixMs, MycProviderInstanceId, + MycProviderKind, MycProviderNip44Version, MycProviderOperation, MycProviderOperationId, + MycProviderOperationInput, MycProviderPublicIdentity, MycProviderRole, + MycUntrustedProviderOutput, +}; 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 @@ -0,0 +1,1170 @@ +//! Typed, side-effect-free Myc signer-provider contract. + +use core::fmt; +use std::error::Error; +use std::path::PathBuf; + +use nostr::PublicKey; +use radroots_runtime_paths::ServiceCredentialArtifactName; +use serde_json::Value; +use zeroize::Zeroizing; + +/// Exact supported signer-provider contract version. +pub const MYC_PROVIDER_CONTRACT_VERSION: u32 = 1; +/// Maximum semantic provider input admitted before any wire encoding. +pub const MYC_PROVIDER_INPUT_MAX_BYTES: usize = 262_144; +/// Maximum untrusted provider output admitted before semantic verification. +pub const MYC_PROVIDER_OUTPUT_MAX_BYTES: usize = 1_048_576; +/// Maximum configured local-signer request deadline. +pub const MYC_PROVIDER_REQUEST_DEADLINE_MAX_MS: u64 = 30_000; +/// Maximum configured local-signer request body. +pub const MYC_PROVIDER_REQUEST_MAX_BYTES: u64 = 65_536; +/// Maximum configured local-signer response body. +pub const MYC_PROVIDER_RESPONSE_MAX_BYTES: u64 = 1_048_576; +/// Maximum configured local-signer concurrent operations. +pub const MYC_PROVIDER_CONCURRENCY_MAX: u32 = 64; + +/// One explicit Myc identity role. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum MycProviderRole { + Transport, + User, + Discovery, +} + +impl MycProviderRole { + /// Returns the exact contract spelling. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Transport => "transport", + Self::User => "user", + Self::Discovery => "discovery", + } + } + + const fn config_pointer(self) -> &'static str { + match self { + Self::Transport => "/identity/transport", + Self::User => "/identity/user", + Self::Discovery => "/identity/discovery/binding", + } + } +} + +/// The only supported provider implementations. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum MycProviderKind { + EncryptedFile, + LocalSigner, +} + +impl MycProviderKind { + /// Returns the exact configuration spelling. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::EncryptedFile => "encrypted_file", + Self::LocalSigner => "local_signer", + } + } +} + +/// The complete closed signer-provider capability inventory. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum MycProviderCapability { + Describe, + PublicIdentity, + SignEvent, + Nip04Encrypt, + Nip04Decrypt, + Nip44Encrypt, + Nip44Decrypt, +} + +impl MycProviderCapability { + const ALL: [Self; 7] = [ + Self::Describe, + Self::PublicIdentity, + Self::SignEvent, + Self::Nip04Encrypt, + Self::Nip04Decrypt, + Self::Nip44Encrypt, + Self::Nip44Decrypt, + ]; + + /// Returns the exact protocol spelling. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Describe => "describe", + Self::PublicIdentity => "public_identity", + Self::SignEvent => "sign_event", + Self::Nip04Encrypt => "nip04_encrypt", + Self::Nip04Decrypt => "nip04_decrypt", + Self::Nip44Encrypt => "nip44_encrypt", + Self::Nip44Decrypt => "nip44_decrypt", + } + } + + const fn bit(self) -> u8 { + match self { + Self::Describe => 1 << 0, + Self::PublicIdentity => 1 << 1, + Self::SignEvent => 1 << 2, + Self::Nip04Encrypt => 1 << 3, + Self::Nip04Decrypt => 1 << 4, + Self::Nip44Encrypt => 1 << 5, + Self::Nip44Decrypt => 1 << 6, + } + } +} + +/// One duplicate-free bounded capability set. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct MycProviderCapabilitySet(u8); + +impl MycProviderCapabilitySet { + /// Constructs a set and rejects duplicate or empty inventories. + pub fn new(capabilities: &[MycProviderCapability]) -> Result<Self, MycProviderContractError> { + if capabilities.is_empty() || capabilities.len() > MycProviderCapability::ALL.len() { + return Err(contract_error( + MycProviderContractErrorKind::InvalidCapabilitySet, + )); + } + let mut bits = 0_u8; + for capability in capabilities { + let bit = capability.bit(); + if bits & bit != 0 { + return Err(contract_error( + MycProviderContractErrorKind::InvalidCapabilitySet, + )); + } + bits |= bit; + } + Ok(Self(bits)) + } + + const fn for_role(role: MycProviderRole) -> Self { + let common = + MycProviderCapability::Describe.bit() | MycProviderCapability::PublicIdentity.bit(); + match role { + MycProviderRole::Transport => Self( + common + | MycProviderCapability::Nip04Encrypt.bit() + | MycProviderCapability::Nip04Decrypt.bit() + | MycProviderCapability::Nip44Encrypt.bit() + | MycProviderCapability::Nip44Decrypt.bit(), + ), + MycProviderRole::User => Self( + common + | MycProviderCapability::SignEvent.bit() + | MycProviderCapability::Nip04Encrypt.bit() + | MycProviderCapability::Nip04Decrypt.bit() + | MycProviderCapability::Nip44Encrypt.bit() + | MycProviderCapability::Nip44Decrypt.bit(), + ), + MycProviderRole::Discovery => Self(common | MycProviderCapability::SignEvent.bit()), + } + } + + /// Returns whether this set contains the capability. + #[must_use] + pub const fn contains(self, capability: MycProviderCapability) -> bool { + self.0 & capability.bit() != 0 + } + + /// Returns the exact number of capabilities. + #[must_use] + pub const fn len(self) -> usize { + self.0.count_ones() as usize + } + + /// Returns whether the set is empty. + #[must_use] + pub const fn is_empty(self) -> bool { + self.0 == 0 + } + + /// Iterates in the exact governed inventory order. + pub fn iter(self) -> impl Iterator<Item = MycProviderCapability> { + MycProviderCapability::ALL + .into_iter() + .filter(move |capability| self.contains(*capability)) + } +} + +impl fmt::Debug for MycProviderCapabilitySet { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_list().entries(self.iter()).finish() + } +} + +/// The role-assignment identity carried by every provider call. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum MycProviderInstanceId { + Transport, + User, + Discovery, +} + +impl MycProviderInstanceId { + /// Returns the exact stable instance spelling. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Transport => "transport", + Self::User => "user", + Self::Discovery => "discovery", + } + } +} + +impl From<MycProviderRole> for MycProviderInstanceId { + fn from(role: MycProviderRole) -> Self { + match role { + MycProviderRole::Transport => Self::Transport, + MycProviderRole::User => Self::User, + MycProviderRole::Discovery => Self::Discovery, + } + } +} + +/// One canonical expected or peer Nostr public identity. +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct MycProviderPublicIdentity(Box<str>); + +impl MycProviderPublicIdentity { + /// Validates exact lowercase 32-byte x-only public-key hex before allocation. + pub fn new(value: &str) -> Result<Self, MycProviderContractError> { + if value.len() != 64 + || value + .bytes() + .any(|byte| !byte.is_ascii_hexdigit() || byte.is_ascii_uppercase()) + { + return Err(contract_error( + MycProviderContractErrorKind::InvalidIdentity, + )); + } + let public_key = PublicKey::from_hex(value) + .map_err(|_| contract_error(MycProviderContractErrorKind::InvalidIdentity))?; + public_key + .xonly() + .map_err(|_| contract_error(MycProviderContractErrorKind::InvalidIdentity))?; + if public_key.to_hex() != value { + return Err(contract_error( + MycProviderContractErrorKind::InvalidIdentity, + )); + } + Ok(Self(value.into())) + } + + /// Returns the exact canonical public-key hex. + #[must_use] + pub fn as_hex(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for MycProviderPublicIdentity { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MycProviderPublicIdentity([redacted])") + } +} + +/// One validated logical wrapping-credential reference. +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct MycProviderCredentialReference(ServiceCredentialArtifactName); + +impl MycProviderCredentialReference { + /// Validates the shared service credential-artifact vocabulary. + pub fn new(value: &str) -> Result<Self, MycProviderContractError> { + ServiceCredentialArtifactName::new(value) + .map(Self) + .map_err(|_| contract_error(MycProviderContractErrorKind::InvalidCredentialReference)) + } + + /// Returns the exact logical reference, never credential material. + #[must_use] + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl fmt::Debug for MycProviderCredentialReference { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MycProviderCredentialReference([redacted])") + } +} + +/// Validated local-signer transport limits. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MycLocalSignerLimits { + request_deadline_ms: u64, + request_max_bytes: u64, + response_max_bytes: u64, + concurrency: u32, +} + +impl MycLocalSignerLimits { + /// Validates every configured transport resource limit. + pub fn new( + request_deadline_ms: u64, + request_max_bytes: u64, + response_max_bytes: u64, + concurrency: u32, + ) -> Result<Self, MycProviderContractError> { + if !(1..=MYC_PROVIDER_REQUEST_DEADLINE_MAX_MS).contains(&request_deadline_ms) + || !(1..=MYC_PROVIDER_REQUEST_MAX_BYTES).contains(&request_max_bytes) + || !(1..=MYC_PROVIDER_RESPONSE_MAX_BYTES).contains(&response_max_bytes) + || !(1..=MYC_PROVIDER_CONCURRENCY_MAX).contains(&concurrency) + { + return Err(contract_error(MycProviderContractErrorKind::InvalidLimits)); + } + Ok(Self { + request_deadline_ms, + request_max_bytes, + response_max_bytes, + concurrency, + }) + } + + #[must_use] + pub const fn request_deadline_ms(self) -> u64 { + self.request_deadline_ms + } + + #[must_use] + pub const fn request_max_bytes(self) -> u64 { + self.request_max_bytes + } + + #[must_use] + pub const fn response_max_bytes(self) -> u64 { + self.response_max_bytes + } + + #[must_use] + pub const fn concurrency(self) -> u32 { + self.concurrency + } +} + +#[derive(Clone, PartialEq, Eq)] +enum ProviderLocation { + EncryptedFile { + envelope_path: PathBuf, + credential_reference: MycProviderCredentialReference, + }, + LocalSigner { + socket_path: PathBuf, + limits: MycLocalSignerLimits, + }, +} + +/// One immutable provider assignment for one explicit identity role. +#[derive(Clone, PartialEq, Eq)] +pub struct MycProviderBinding { + role: MycProviderRole, + instance: MycProviderInstanceId, + kind: MycProviderKind, + expected_identity: MycProviderPublicIdentity, + required_capabilities: MycProviderCapabilitySet, + location: ProviderLocation, +} + +impl MycProviderBinding { + #[must_use] + pub const fn role(&self) -> MycProviderRole { + self.role + } + + #[must_use] + pub const fn instance(&self) -> MycProviderInstanceId { + self.instance + } + + #[must_use] + pub const fn kind(&self) -> MycProviderKind { + self.kind + } + + #[must_use] + pub const fn expected_identity(&self) -> &MycProviderPublicIdentity { + &self.expected_identity + } + + #[must_use] + pub const fn required_capabilities(&self) -> MycProviderCapabilitySet { + self.required_capabilities + } + + #[must_use] + pub const fn credential_reference(&self) -> Option<&MycProviderCredentialReference> { + match &self.location { + ProviderLocation::EncryptedFile { + credential_reference, + .. + } => Some(credential_reference), + ProviderLocation::LocalSigner { .. } => None, + } + } + + #[must_use] + pub const fn local_signer_limits(&self) -> Option<MycLocalSignerLimits> { + match &self.location { + ProviderLocation::EncryptedFile { .. } => None, + ProviderLocation::LocalSigner { limits, .. } => Some(*limits), + } + } + + fn location_is_valid(&self) -> bool { + match &self.location { + ProviderLocation::EncryptedFile { + envelope_path, + credential_reference, + } => envelope_path.is_absolute() && !credential_reference.as_str().is_empty(), + ProviderLocation::LocalSigner { + socket_path, + limits, + } => socket_path.is_absolute() && limits.concurrency() > 0, + } + } +} + +impl fmt::Debug for MycProviderBinding { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycProviderBinding") + .field("role", &self.role) + .field("instance", &self.instance) + .field("kind", &self.kind) + .field("expected_identity", &"[redacted]") + .field("required_capabilities", &self.required_capabilities) + .field("location", &"[redacted]") + .field("location_valid", &self.location_is_valid()) + .finish() + } +} + +/// The exact provider assignments derived from one admitted v1 configuration. +#[derive(PartialEq, Eq)] +pub struct MycProviderContract { + bindings: Box<[MycProviderBinding]>, +} + +impl MycProviderContract { + pub(crate) fn from_normalized(document: &Value) -> Result<Self, MycProviderContractError> { + let mut bindings = Vec::with_capacity(3); + bindings.push(binding_from_config(document, MycProviderRole::Transport)?); + bindings.push(binding_from_config(document, MycProviderRole::User)?); + if json_bool(document, "/identity/discovery/enabled")? { + bindings.push(binding_from_config(document, MycProviderRole::Discovery)?); + } + if bindings.iter().enumerate().any(|(index, binding)| { + bindings[index + 1..] + .iter() + .any(|other| binding.expected_identity == other.expected_identity) + }) { + return Err(contract_error( + MycProviderContractErrorKind::InvalidIdentity, + )); + } + Ok(Self { + bindings: bindings.into_boxed_slice(), + }) + } + + /// Returns all enabled bindings in transport, user, discovery order. + #[must_use] + pub fn bindings(&self) -> &[MycProviderBinding] { + &self.bindings + } + + /// Returns the binding for one enabled role. + #[must_use] + pub fn binding(&self, role: MycProviderRole) -> Option<&MycProviderBinding> { + self.bindings.iter().find(|binding| binding.role == role) + } +} + +impl fmt::Debug for MycProviderContract { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycProviderContract") + .field("version", &MYC_PROVIDER_CONTRACT_VERSION) + .field("binding_count", &self.bindings.len()) + .field( + "roles", + &self + .bindings + .iter() + .map(|binding| binding.role) + .collect::<Vec<_>>(), + ) + .finish() + } +} + +fn binding_from_config( + document: &Value, + role: MycProviderRole, +) -> Result<MycProviderBinding, MycProviderContractError> { + let prefix = role.config_pointer(); + let provider = json_string(document, &format!("{prefix}/provider"))?; + let expected_identity = MycProviderPublicIdentity::new(json_string( + document, + &format!("{prefix}/expected_public_key"), + )?)?; + let (kind, location) = match provider { + "encrypted_file" => { + let envelope_path = + PathBuf::from(json_string(document, &format!("{prefix}/envelope_path"))?); + if !envelope_path.is_absolute() { + return Err(contract_error( + MycProviderContractErrorKind::InvalidConfiguration, + )); + } + let credential_reference = MycProviderCredentialReference::new(json_string( + document, + &format!("{prefix}/credential_reference"), + )?)?; + ( + MycProviderKind::EncryptedFile, + ProviderLocation::EncryptedFile { + envelope_path, + credential_reference, + }, + ) + } + "local_signer" => { + let socket_path = + PathBuf::from(json_string(document, &format!("{prefix}/socket_path"))?); + if !socket_path.is_absolute() { + return Err(contract_error( + MycProviderContractErrorKind::InvalidConfiguration, + )); + } + let limits = MycLocalSignerLimits::new( + json_u64(document, &format!("{prefix}/request_deadline_ms"))?, + json_u64(document, &format!("{prefix}/request_max_bytes"))?, + json_u64(document, &format!("{prefix}/response_max_bytes"))?, + u32::try_from(json_u64(document, &format!("{prefix}/concurrency"))?) + .map_err(|_| contract_error(MycProviderContractErrorKind::InvalidLimits))?, + )?; + ( + MycProviderKind::LocalSigner, + ProviderLocation::LocalSigner { + socket_path, + limits, + }, + ) + } + _ => { + return Err(contract_error( + MycProviderContractErrorKind::InvalidConfiguration, + )); + } + }; + Ok(MycProviderBinding { + role, + instance: role.into(), + kind, + expected_identity, + required_capabilities: MycProviderCapabilitySet::for_role(role), + location, + }) +} + +/// Positive absolute UTC millisecond deadline for one provider call. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct MycProviderDeadlineUnixMs(u64); + +impl MycProviderDeadlineUnixMs { + /// Validates a positive deadline representable by governed storage/time types. + pub fn new(value: u64) -> Result<Self, MycProviderContractError> { + if value == 0 || i64::try_from(value).is_err() { + return Err(contract_error( + MycProviderContractErrorKind::InvalidDeadline, + )); + } + Ok(Self(value)) + } + + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } +} + +macro_rules! provider_identity { + ($name:ident) => { + #[derive(Clone, Copy, PartialEq, Eq, Hash)] + pub struct $name([u8; 32]); + + impl $name { + /// Wraps exact stable identity bytes. + #[must_use] + pub const fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + /// Returns the exact identity bytes. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + } + + impl fmt::Debug for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(concat!(stringify!($name), "([redacted])")) + } + } + }; +} + +provider_identity!(MycProviderOperationId); +provider_identity!(MycProviderCorrelationId); + +/// Exact supported NIP-44 provider-operation version. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum MycProviderNip44Version { + V2, +} + +impl MycProviderNip44Version { + #[must_use] + pub const fn as_u8(self) -> u8 { + match self { + Self::V2 => 2, + } + } +} + +enum ProviderOperationInputKind { + Describe, + PublicIdentity, + SignEvent(Zeroizing<Vec<u8>>), + Nip04Encrypt { + peer: MycProviderPublicIdentity, + plaintext: Zeroizing<Vec<u8>>, + }, + Nip04Decrypt { + peer: MycProviderPublicIdentity, + ciphertext: Zeroizing<Vec<u8>>, + }, + Nip44Encrypt { + peer: MycProviderPublicIdentity, + version: MycProviderNip44Version, + plaintext: Zeroizing<Vec<u8>>, + }, + Nip44Decrypt { + peer: MycProviderPublicIdentity, + version: MycProviderNip44Version, + ciphertext: Zeroizing<Vec<u8>>, + }, +} + +/// One bounded, non-forgeable semantic provider operation input. +pub struct MycProviderOperationInput { + kind: ProviderOperationInputKind, +} + +impl MycProviderOperationInput { + #[must_use] + pub const fn describe() -> Self { + Self { + kind: ProviderOperationInputKind::Describe, + } + } + + #[must_use] + pub const fn public_identity() -> Self { + Self { + kind: ProviderOperationInputKind::PublicIdentity, + } + } + + pub fn sign_event(canonical_unsigned_event: &[u8]) -> Result<Self, MycProviderContractError> { + Ok(Self { + kind: ProviderOperationInputKind::SignEvent(copy_input( + canonical_unsigned_event, + false, + )?), + }) + } + + pub fn nip04_encrypt( + peer: MycProviderPublicIdentity, + plaintext: &[u8], + ) -> Result<Self, MycProviderContractError> { + Ok(Self { + kind: ProviderOperationInputKind::Nip04Encrypt { + peer, + plaintext: copy_input(plaintext, true)?, + }, + }) + } + + pub fn nip04_decrypt( + peer: MycProviderPublicIdentity, + ciphertext: &[u8], + ) -> Result<Self, MycProviderContractError> { + Ok(Self { + kind: ProviderOperationInputKind::Nip04Decrypt { + peer, + ciphertext: copy_input(ciphertext, false)?, + }, + }) + } + + pub fn nip44_encrypt( + peer: MycProviderPublicIdentity, + version: MycProviderNip44Version, + plaintext: &[u8], + ) -> Result<Self, MycProviderContractError> { + Ok(Self { + kind: ProviderOperationInputKind::Nip44Encrypt { + peer, + version, + plaintext: copy_input(plaintext, true)?, + }, + }) + } + + pub fn nip44_decrypt( + peer: MycProviderPublicIdentity, + version: MycProviderNip44Version, + ciphertext: &[u8], + ) -> Result<Self, MycProviderContractError> { + Ok(Self { + kind: ProviderOperationInputKind::Nip44Decrypt { + peer, + version, + ciphertext: copy_input(ciphertext, false)?, + }, + }) + } + + /// Returns the exact capability selected by this input. + #[must_use] + pub const fn capability(&self) -> MycProviderCapability { + match &self.kind { + ProviderOperationInputKind::Describe => MycProviderCapability::Describe, + ProviderOperationInputKind::PublicIdentity => MycProviderCapability::PublicIdentity, + ProviderOperationInputKind::SignEvent(_) => MycProviderCapability::SignEvent, + ProviderOperationInputKind::Nip04Encrypt { .. } => MycProviderCapability::Nip04Encrypt, + ProviderOperationInputKind::Nip04Decrypt { .. } => MycProviderCapability::Nip04Decrypt, + ProviderOperationInputKind::Nip44Encrypt { .. } => MycProviderCapability::Nip44Encrypt, + ProviderOperationInputKind::Nip44Decrypt { .. } => MycProviderCapability::Nip44Decrypt, + } + } + + /// Returns the peer identity for peer-bound cryptographic operations. + #[must_use] + pub const fn peer(&self) -> Option<&MycProviderPublicIdentity> { + match &self.kind { + ProviderOperationInputKind::Nip04Encrypt { peer, .. } + | ProviderOperationInputKind::Nip04Decrypt { peer, .. } + | ProviderOperationInputKind::Nip44Encrypt { peer, .. } + | ProviderOperationInputKind::Nip44Decrypt { peer, .. } => Some(peer), + ProviderOperationInputKind::Describe + | ProviderOperationInputKind::PublicIdentity + | ProviderOperationInputKind::SignEvent(_) => None, + } + } + + /// Returns the exact protected or event bytes, if any. + #[must_use] + pub fn bytes(&self) -> Option<&[u8]> { + match &self.kind { + ProviderOperationInputKind::SignEvent(bytes) => Some(bytes), + ProviderOperationInputKind::Nip04Encrypt { plaintext, .. } + | ProviderOperationInputKind::Nip44Encrypt { plaintext, .. } => Some(plaintext), + ProviderOperationInputKind::Nip04Decrypt { ciphertext, .. } + | ProviderOperationInputKind::Nip44Decrypt { ciphertext, .. } => Some(ciphertext), + ProviderOperationInputKind::Describe | ProviderOperationInputKind::PublicIdentity => { + None + } + } + } + + #[must_use] + pub const fn nip44_version(&self) -> Option<MycProviderNip44Version> { + match &self.kind { + ProviderOperationInputKind::Nip44Encrypt { version, .. } + | ProviderOperationInputKind::Nip44Decrypt { version, .. } => Some(*version), + _ => None, + } + } +} + +impl fmt::Debug for MycProviderOperationInput { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycProviderOperationInput") + .field("capability", &self.capability()) + .field("peer", &self.peer().map(|_| "[redacted]")) + .field("payload", &self.bytes().map(|_| "[redacted]")) + .field("nip44_version", &self.nip44_version()) + .finish() + } +} + +fn copy_input( + bytes: &[u8], + empty_allowed: bool, +) -> Result<Zeroizing<Vec<u8>>, MycProviderContractError> { + if (!empty_allowed && bytes.is_empty()) || bytes.len() > MYC_PROVIDER_INPUT_MAX_BYTES { + return Err(contract_error(MycProviderContractErrorKind::InvalidInput)); + } + Ok(Zeroizing::new(bytes.to_vec())) +} + +/// One fully bound provider operation before provider selection/execution. +pub struct MycProviderOperation { + role: MycProviderRole, + instance: MycProviderInstanceId, + provider: MycProviderKind, + operation_id: MycProviderOperationId, + correlation_id: MycProviderCorrelationId, + deadline: MycProviderDeadlineUnixMs, + expected_identity: MycProviderPublicIdentity, + input: MycProviderOperationInput, +} + +impl MycProviderOperation { + /// Binds one operation to the exact configured provider assignment. + pub fn new( + binding: &MycProviderBinding, + operation_id: MycProviderOperationId, + correlation_id: MycProviderCorrelationId, + deadline: MycProviderDeadlineUnixMs, + input: MycProviderOperationInput, + ) -> Result<Self, MycProviderContractError> { + if !binding.required_capabilities.contains(input.capability()) { + return Err(contract_error( + MycProviderContractErrorKind::UnsupportedOperation, + )); + } + Ok(Self { + role: binding.role, + instance: binding.instance, + provider: binding.kind, + operation_id, + correlation_id, + deadline, + expected_identity: binding.expected_identity.clone(), + input, + }) + } + + #[must_use] + pub const fn contract_version(&self) -> u32 { + MYC_PROVIDER_CONTRACT_VERSION + } + + #[must_use] + pub const fn role(&self) -> MycProviderRole { + self.role + } + + #[must_use] + pub const fn instance(&self) -> MycProviderInstanceId { + self.instance + } + + #[must_use] + pub const fn provider(&self) -> MycProviderKind { + self.provider + } + + #[must_use] + pub const fn operation_id(&self) -> MycProviderOperationId { + self.operation_id + } + + #[must_use] + pub const fn correlation_id(&self) -> MycProviderCorrelationId { + self.correlation_id + } + + #[must_use] + pub const fn deadline(&self) -> MycProviderDeadlineUnixMs { + self.deadline + } + + #[must_use] + pub const fn expected_identity(&self) -> &MycProviderPublicIdentity { + &self.expected_identity + } + + #[must_use] + pub const fn input(&self) -> &MycProviderOperationInput { + &self.input + } +} + +impl fmt::Debug for MycProviderOperation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycProviderOperation") + .field("contract_version", &MYC_PROVIDER_CONTRACT_VERSION) + .field("role", &self.role) + .field("instance", &self.instance) + .field("provider", &self.provider) + .field("operation_id", &"[redacted]") + .field("correlation_id", &"[redacted]") + .field("deadline", &"[redacted]") + .field("expected_identity", &"[redacted]") + .field("input", &self.input) + .finish() + } +} + +/// One bounded untrusted provider result body before independent verification. +pub struct MycUntrustedProviderOutput(Zeroizing<Vec<u8>>); + +impl MycUntrustedProviderOutput { + /// Copies only after enforcing the hard response bound. + pub fn new(bytes: &[u8]) -> Result<Self, MycProviderContractError> { + if bytes.len() > MYC_PROVIDER_OUTPUT_MAX_BYTES { + return Err(contract_error(MycProviderContractErrorKind::InvalidOutput)); + } + Ok(Self(Zeroizing::new(bytes.to_vec()))) + } + + /// Returns the untrusted bytes for the independent verifier only. + #[must_use] + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } +} + +impl fmt::Debug for MycUntrustedProviderOutput { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MycUntrustedProviderOutput([redacted])") + } +} + +/// Stable source-free provider contract failure classification. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycProviderContractErrorKind { + InvalidConfiguration, + InvalidIdentity, + InvalidCredentialReference, + InvalidLimits, + InvalidCapabilitySet, + InvalidDeadline, + InvalidInput, + InvalidOutput, + UnsupportedOperation, +} + +impl MycProviderContractErrorKind { + const fn message(self) -> &'static str { + match self { + Self::InvalidConfiguration => "provider configuration is invalid", + Self::InvalidIdentity => "provider identity is invalid", + Self::InvalidCredentialReference => "provider credential reference is invalid", + Self::InvalidLimits => "provider resource limits are invalid", + Self::InvalidCapabilitySet => "provider capability set is invalid", + Self::InvalidDeadline => "provider deadline is invalid", + Self::InvalidInput => "provider operation input is invalid", + Self::InvalidOutput => "provider operation output is invalid", + Self::UnsupportedOperation => "provider operation is unsupported for the role", + } + } +} + +/// One source-free provider contract failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct MycProviderContractError { + kind: MycProviderContractErrorKind, +} + +impl MycProviderContractError { + #[must_use] + pub const fn kind(self) -> MycProviderContractErrorKind { + self.kind + } +} + +impl fmt::Debug for MycProviderContractError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycProviderContractError") + .field("kind", &self.kind) + .finish() + } +} + +impl fmt::Display for MycProviderContractError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.kind.message()) + } +} + +impl Error for MycProviderContractError {} + +const fn contract_error(kind: MycProviderContractErrorKind) -> MycProviderContractError { + MycProviderContractError { kind } +} + +fn json_string<'a>( + document: &'a Value, + pointer: &str, +) -> Result<&'a str, MycProviderContractError> { + document + .pointer(pointer) + .and_then(Value::as_str) + .ok_or_else(|| contract_error(MycProviderContractErrorKind::InvalidConfiguration)) +} + +fn json_u64(document: &Value, pointer: &str) -> Result<u64, MycProviderContractError> { + document + .pointer(pointer) + .and_then(Value::as_u64) + .ok_or_else(|| contract_error(MycProviderContractErrorKind::InvalidConfiguration)) +} + +fn json_bool(document: &Value, pointer: &str) -> Result<bool, MycProviderContractError> { + document + .pointer(pointer) + .and_then(Value::as_bool) + .ok_or_else(|| contract_error(MycProviderContractErrorKind::InvalidConfiguration)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn capability_sets_are_closed_ordered_and_duplicate_free() { + let transport = MycProviderCapabilitySet::for_role(MycProviderRole::Transport); + assert_eq!(transport.len(), 6); + assert!(!transport.contains(MycProviderCapability::SignEvent)); + assert_eq!( + MycProviderCapabilitySet::for_role(MycProviderRole::User) + .iter() + .collect::<Vec<_>>(), + MycProviderCapability::ALL + ); + assert_eq!( + MycProviderCapabilitySet::for_role(MycProviderRole::Discovery) + .iter() + .collect::<Vec<_>>(), + vec![ + MycProviderCapability::Describe, + MycProviderCapability::PublicIdentity, + MycProviderCapability::SignEvent, + ] + ); + assert_eq!( + MycProviderCapabilitySet::new(&[ + MycProviderCapability::Describe, + MycProviderCapability::Describe, + ]) + .unwrap_err() + .kind(), + MycProviderContractErrorKind::InvalidCapabilitySet + ); + assert!(MycProviderCapabilitySet::new(&[]).is_err()); + } + + #[test] + fn identities_references_limits_and_deadlines_are_exactly_bounded() { + let identity = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + assert!(MycProviderPublicIdentity::new(identity).is_ok()); + assert!(MycProviderPublicIdentity::new(&identity.to_uppercase()).is_err()); + assert!(MycProviderPublicIdentity::new(&"1".repeat(63)).is_err()); + + assert!(MycProviderCredentialReference::new("a").is_ok()); + assert!(MycProviderCredentialReference::new(&"a".repeat(128)).is_ok()); + assert!(MycProviderCredentialReference::new(&"a".repeat(129)).is_err()); + assert!(MycProviderCredentialReference::new("../credential").is_err()); + + assert!(MycLocalSignerLimits::new(1, 1, 1, 1).is_ok()); + assert!( + MycLocalSignerLimits::new( + MYC_PROVIDER_REQUEST_DEADLINE_MAX_MS, + MYC_PROVIDER_REQUEST_MAX_BYTES, + MYC_PROVIDER_RESPONSE_MAX_BYTES, + MYC_PROVIDER_CONCURRENCY_MAX, + ) + .is_ok() + ); + for result in [ + MycLocalSignerLimits::new(0, 1, 1, 1), + MycLocalSignerLimits::new(1, 0, 1, 1), + MycLocalSignerLimits::new(1, 1, 0, 1), + MycLocalSignerLimits::new(1, 1, 1, 0), + MycLocalSignerLimits::new(MYC_PROVIDER_REQUEST_DEADLINE_MAX_MS + 1, 1, 1, 1), + MycLocalSignerLimits::new(1, MYC_PROVIDER_REQUEST_MAX_BYTES + 1, 1, 1), + MycLocalSignerLimits::new(1, 1, MYC_PROVIDER_RESPONSE_MAX_BYTES + 1, 1), + MycLocalSignerLimits::new(1, 1, 1, MYC_PROVIDER_CONCURRENCY_MAX + 1), + ] { + assert_eq!( + result.unwrap_err().kind(), + MycProviderContractErrorKind::InvalidLimits + ); + } + + assert!(MycProviderDeadlineUnixMs::new(1).is_ok()); + assert!(MycProviderDeadlineUnixMs::new(i64::MAX as u64).is_ok()); + assert!(MycProviderDeadlineUnixMs::new(0).is_err()); + assert!(MycProviderDeadlineUnixMs::new(i64::MAX as u64 + 1).is_err()); + } + + #[test] + fn operation_inputs_are_bounded_before_copy_and_redacted() { + let peer = MycProviderPublicIdentity::new(&"2".repeat(64)).expect("peer"); + let exact = vec![b'x'; MYC_PROVIDER_INPUT_MAX_BYTES]; + assert!(MycProviderOperationInput::sign_event(&exact).is_ok()); + assert!(MycProviderOperationInput::sign_event(&[]).is_err()); + assert!( + MycProviderOperationInput::sign_event(&vec![b'x'; MYC_PROVIDER_INPUT_MAX_BYTES + 1]) + .is_err() + ); + let encrypt = MycProviderOperationInput::nip44_encrypt( + peer, + MycProviderNip44Version::V2, + b"protected-value", + ) + .expect("operation"); + let rendered = format!("{encrypt:?}"); + assert!(!rendered.contains("protected-value")); + assert_eq!(encrypt.capability(), MycProviderCapability::Nip44Encrypt); + assert_eq!( + encrypt.nip44_version().map(MycProviderNip44Version::as_u8), + Some(2) + ); + + assert!(MycUntrustedProviderOutput::new(&vec![0; MYC_PROVIDER_OUTPUT_MAX_BYTES]).is_ok()); + assert!( + MycUntrustedProviderOutput::new(&vec![0; MYC_PROVIDER_OUTPUT_MAX_BYTES + 1]).is_err() + ); + } + + #[test] + fn every_public_error_is_fixed_and_source_free() { + for kind in [ + MycProviderContractErrorKind::InvalidConfiguration, + MycProviderContractErrorKind::InvalidIdentity, + MycProviderContractErrorKind::InvalidCredentialReference, + MycProviderContractErrorKind::InvalidLimits, + MycProviderContractErrorKind::InvalidCapabilitySet, + MycProviderContractErrorKind::InvalidDeadline, + MycProviderContractErrorKind::InvalidInput, + MycProviderContractErrorKind::InvalidOutput, + MycProviderContractErrorKind::UnsupportedOperation, + ] { + let error = contract_error(kind); + assert_eq!(error.kind(), kind); + assert!(!error.to_string().is_empty()); + assert!(error.source().is_none()); + assert!(!format!("{error:?}").contains('/')); + } + } +} diff --git a/tests/services_hardening_provider_contract.rs b/tests/services_hardening_provider_contract.rs @@ -0,0 +1,313 @@ +use std::error::Error; +use std::path::PathBuf; + +use myc::{ + MYC_PROVIDER_CONCURRENCY_MAX, MYC_PROVIDER_CONTRACT_VERSION, MYC_PROVIDER_INPUT_MAX_BYTES, + MYC_PROVIDER_OUTPUT_MAX_BYTES, MYC_PROVIDER_REQUEST_DEADLINE_MAX_MS, + MYC_PROVIDER_REQUEST_MAX_BYTES, MYC_PROVIDER_RESPONSE_MAX_BYTES, MycConfigProfile, + MycProviderCapability, MycProviderContractErrorKind, MycProviderCorrelationId, + MycProviderDeadlineUnixMs, MycProviderKind, MycProviderNip44Version, MycProviderOperation, + MycProviderOperationId, MycProviderOperationInput, MycProviderRole, MycUntrustedProviderOutput, + parse_myc_config_v1, +}; +use serde_json::{Value, json}; + +const CONFIG: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); +const CONTRACT: &str = include_str!("../contracts/services_hardening/provider_contract.v1.json"); + +#[test] +fn machine_contract_freezes_the_complete_provider_inventory() { + let actual: Value = serde_json::from_str(CONTRACT).expect("provider contract JSON"); + assert_eq!( + actual, + json!({ + "schema": "radroots.myc.provider-contract", + "schema_version": 1, + "contract_version": MYC_PROVIDER_CONTRACT_VERSION, + "provider_kinds": ["encrypted_file", "local_signer"], + "capabilities": [ + "describe", + "public_identity", + "sign_event", + "nip04_encrypt", + "nip04_decrypt", + "nip44_encrypt", + "nip44_decrypt" + ], + "roles": [ + { + "role": "transport", + "provider_instance": "transport", + "required_capabilities": [ + "describe", + "public_identity", + "nip04_encrypt", + "nip04_decrypt", + "nip44_encrypt", + "nip44_decrypt" + ] + }, + { + "role": "user", + "provider_instance": "user", + "required_capabilities": [ + "describe", + "public_identity", + "sign_event", + "nip04_encrypt", + "nip04_decrypt", + "nip44_encrypt", + "nip44_decrypt" + ] + }, + { + "role": "discovery", + "provider_instance": "discovery", + "required_capabilities": ["describe", "public_identity", "sign_event"] + } + ], + "operation_binding": { + "operation_id_bytes": 32, + "correlation_id_bytes": 32, + "absolute_deadline_unix_ms": { + "minimum": 1, + "maximum": i64::MAX + }, + "nip44_versions": [2], + "semantic_input_max_bytes": MYC_PROVIDER_INPUT_MAX_BYTES, + "untrusted_output_max_bytes": MYC_PROVIDER_OUTPUT_MAX_BYTES + }, + "local_signer_limits": { + "request_deadline_ms": { + "minimum": 1, + "maximum": MYC_PROVIDER_REQUEST_DEADLINE_MAX_MS + }, + "request_max_bytes": { + "minimum": 1, + "maximum": MYC_PROVIDER_REQUEST_MAX_BYTES + }, + "response_max_bytes": { + "minimum": 1, + "maximum": MYC_PROVIDER_RESPONSE_MAX_BYTES + }, + "concurrency": { + "minimum": 1, + "maximum": MYC_PROVIDER_CONCURRENCY_MAX + } + }, + "credential_reference": { + "maximum_utf8_bytes": 128, + "shared_type": "ServiceCredentialArtifactName", + "material_in_configuration": false + }, + "execution": { + "database_transaction_held": false, + "provider_result_trusted": false, + "cancellation_proves_no_effect": false, + "signing_is_publication": false + } + }) + ); +} + +#[test] +fn admitted_configuration_derives_exact_role_bindings_and_limits() { + let config = parse_myc_config_v1(CONFIG.as_bytes(), MycConfigProfile::Production) + .expect("canonical configuration"); + let contract = config.provider_contract(); + assert_eq!(contract.bindings().len(), 3); + + let transport = contract + .binding(MycProviderRole::Transport) + .expect("transport binding"); + assert_eq!(transport.kind(), MycProviderKind::EncryptedFile); + assert_eq!(transport.instance().as_str(), "transport"); + assert_eq!( + transport + .credential_reference() + .expect("credential") + .as_str(), + "transport_wrapping_key" + ); + assert!(transport.local_signer_limits().is_none()); + assert!( + !transport + .required_capabilities() + .contains(MycProviderCapability::SignEvent) + ); + + let user = contract + .binding(MycProviderRole::User) + .expect("user binding"); + assert_eq!(user.kind(), MycProviderKind::LocalSigner); + assert_eq!(user.instance().as_str(), "user"); + assert!(user.credential_reference().is_none()); + let limits = user.local_signer_limits().expect("local signer limits"); + assert_eq!(limits.request_deadline_ms(), 15_000); + assert_eq!(limits.request_max_bytes(), 65_536); + assert_eq!(limits.response_max_bytes(), 1_048_576); + assert_eq!(limits.concurrency(), 32); + assert_eq!(user.required_capabilities().len(), 7); + + let discovery = contract + .binding(MycProviderRole::Discovery) + .expect("discovery binding"); + assert_eq!(discovery.kind(), MycProviderKind::EncryptedFile); + assert_eq!(discovery.required_capabilities().len(), 3); + assert_eq!( + discovery + .required_capabilities() + .iter() + .map(MycProviderCapability::as_str) + .collect::<Vec<_>>(), + ["describe", "public_identity", "sign_event"] + ); +} + +#[test] +fn explicitly_disabled_discovery_omits_only_that_provider_binding() { + let mut value = CONFIG.parse::<toml::Table>().expect("configuration TOML"); + value.insert( + "discovery".to_owned(), + toml::Value::Table(toml::Table::from_iter([( + "enabled".to_owned(), + toml::Value::Boolean(false), + )])), + ); + value + .get_mut("identity") + .and_then(toml::Value::as_table_mut) + .expect("identity") + .insert( + "discovery".to_owned(), + toml::Value::Table(toml::Table::from_iter([( + "enabled".to_owned(), + toml::Value::Boolean(false), + )])), + ); + let source = toml::to_string(&value).expect("disabled discovery fixture"); + let config = parse_myc_config_v1(source.as_bytes(), MycConfigProfile::Production) + .expect("disabled discovery config"); + assert_eq!(config.provider_contract().bindings().len(), 2); + assert!( + config + .provider_contract() + .binding(MycProviderRole::Discovery) + .is_none() + ); +} + +#[test] +fn every_operation_is_identity_deadline_and_role_bound_before_execution() { + let config = parse_myc_config_v1(CONFIG.as_bytes(), MycConfigProfile::Production) + .expect("canonical configuration"); + let operation_id = MycProviderOperationId::from_bytes([7; 32]); + let correlation_id = MycProviderCorrelationId::from_bytes([9; 32]); + let deadline = MycProviderDeadlineUnixMs::new(1_800_000_000_000).expect("deadline"); + + let user = config + .provider_contract() + .binding(MycProviderRole::User) + .expect("user"); + let sign = MycProviderOperation::new( + user, + operation_id, + correlation_id, + deadline, + MycProviderOperationInput::sign_event(br#"{"kind":1}"#).expect("event input"), + ) + .expect("bound operation"); + assert_eq!(sign.contract_version(), MYC_PROVIDER_CONTRACT_VERSION); + assert_eq!(sign.role(), MycProviderRole::User); + assert_eq!(sign.instance().as_str(), "user"); + assert_eq!(sign.provider(), MycProviderKind::LocalSigner); + assert_eq!(sign.operation_id().as_bytes(), &[7; 32]); + assert_eq!(sign.correlation_id().as_bytes(), &[9; 32]); + assert_eq!(sign.deadline().get(), 1_800_000_000_000); + assert_eq!(sign.expected_identity(), user.expected_identity()); + assert_eq!(sign.input().capability(), MycProviderCapability::SignEvent); + + let transport = config + .provider_contract() + .binding(MycProviderRole::Transport) + .expect("transport"); + let rejected = MycProviderOperation::new( + transport, + operation_id, + correlation_id, + deadline, + MycProviderOperationInput::sign_event(br#"{"kind":1}"#).expect("event input"), + ) + .expect_err("transport cannot sign events"); + assert_eq!( + rejected.kind(), + MycProviderContractErrorKind::UnsupportedOperation + ); +} + +#[test] +fn protected_inputs_outputs_bind_nip_version_and_never_render_values() { + let peer = myc::MycProviderPublicIdentity::new(&"2".repeat(64)).expect("peer"); + let input = MycProviderOperationInput::nip44_decrypt( + peer, + MycProviderNip44Version::V2, + b"ciphertext-secret-marker", + ) + .expect("decrypt input"); + assert_eq!(input.capability(), MycProviderCapability::Nip44Decrypt); + assert_eq!( + input.nip44_version().map(MycProviderNip44Version::as_u8), + Some(2) + ); + assert_eq!(input.bytes(), Some(b"ciphertext-secret-marker".as_slice())); + assert!(!format!("{input:?}").contains("ciphertext-secret-marker")); + + let output = + MycUntrustedProviderOutput::new(b"provider-output-secret-marker").expect("bounded output"); + assert_eq!(output.as_bytes(), b"provider-output-secret-marker"); + assert!(!format!("{output:?}").contains("provider-output-secret-marker")); +} + +#[test] +fn all_provider_debug_and_errors_are_path_secret_and_source_free() { + let config = parse_myc_config_v1(CONFIG.as_bytes(), MycConfigProfile::Production) + .expect("canonical configuration"); + let rendered = format!("{:?}", config.provider_contract()); + for forbidden in [ + "/var/lib/radroots", + "/run/radroots", + "transport_wrapping_key", + "4444444444444444", + "2222222222222222", + ] { + assert!(!rendered.contains(forbidden), "leaked {forbidden}"); + for binding in config.provider_contract().bindings() { + assert!(!format!("{binding:?}").contains(forbidden)); + } + } + + let error = MycProviderDeadlineUnixMs::new(0).expect_err("zero deadline"); + assert_eq!(error.kind(), MycProviderContractErrorKind::InvalidDeadline); + assert!(error.source().is_none()); + assert!(!error.to_string().contains('/')); +} + +#[test] +fn provider_contract_has_no_io_or_public_path_escape_hatch() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let source = std::fs::read_to_string(root.join("src/provider_contract.rs")) + .expect("provider contract source"); + for forbidden in [ + "std::fs::", + "tokio::", + "sqlx::", + "reqwest::", + "Command::new", + "pub fn envelope_path", + "pub fn socket_path", + "impl serde::Serialize", + "impl serde::Deserialize", + ] { + assert!(!source.contains(forbidden), "forbidden source: {forbidden}"); + } +}