myc

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

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

provider: add bounded local signer transport

Diffstat:
MAGENTS.md | 6++++++
MCargo.lock | 58+++++++++++++++++++++++++++++++++++++++++++++++++++++++---
MCargo.toml | 1+
MREADME | 11+++++++++++
Acontracts/services_hardening/local_signer_transport.v1.json | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mradroots.lib.source-lock.v1.toml | 2+-
Msrc/lib.rs | 6++++++
Msrc/provider_contract.rs | 8++++++++
Asrc/provider_local_signer.rs | 1026+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/build_policy.rs | 7+++++++
Atests/services_hardening_local_signer_transport.rs | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
11 files changed, 1361 insertions(+), 4 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -57,6 +57,12 @@ repo-local provisioning are external/offline; ordinary run, TOML, environment, arguments, envelope siblings, and state backup never create or carry the credential. +- Local-signer transport is frozen by + `contracts/services_hardening/local_signer_transport.v1.json`. It uses the + hardened Lib `AdminClient` for one fixed HTTP/1.1 JSON endpoint over a Unix + socket, carries the complete provider-operation binding in closed tagged + request/response models, enforces configured body/deadline/concurrency + limits, and returns only semantically untrusted output for Step 135. - 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 @@ -682,7 +682,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1113,6 +1113,7 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", ] [[package]] @@ -1544,6 +1545,7 @@ dependencies = [ "radroots_nostr_connect", "radroots_runtime_paths", "radroots_secrets", + "radroots_service_host", "radroots_service_sqlite", "radroots_signing", "radroots_storage", @@ -2087,6 +2089,27 @@ dependencies = [ ] [[package]] +name = "radroots_service_host" +version = "0.1.0-alpha" +source = "git+https://github.com/radrootslabs/lib?rev=b44119fbac5985be8127ad1bf56d2950e6399427#b44119fbac5985be8127ad1bf56d2950e6399427" +dependencies = [ + "bytes", + "fs2", + "getrandom 0.2.17", + "http", + "http-body-util", + "hyper", + "hyper-util", + "radroots_runtime_paths", + "rustix", + "serde", + "serde_json", + "tokio", + "tokio-util", + "toml", +] + +[[package]] name = "radroots_service_sqlite" version = "0.1.0-alpha" source = "git+https://github.com/radrootslabs/lib?rev=b44119fbac5985be8127ad1bf56d2950e6399427#b44119fbac5985be8127ad1bf56d2950e6399427" @@ -2322,7 +2345,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2825,7 +2848,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3010,6 +3033,20 @@ dependencies = [ ] [[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] name = "toml" version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3152,6 +3189,12 @@ dependencies = [ ] [[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] name = "tungstenite" version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3315,6 +3358,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" [[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -44,6 +44,7 @@ radroots_event = { git = "https://github.com/radrootslabs/lib", rev = "b44119fba radroots_nostr = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha", features = ["events"] } radroots_nostr_connect = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha" } radroots_runtime_paths = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha" } +radroots_service_host = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha" } radroots_service_sqlite = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha" } radroots_secrets = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha", features = ["std", "keyring"] } radroots_signing = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha", features = ["std"] } diff --git a/README b/README @@ -108,6 +108,17 @@ resolution is unsupported, and TOML, environment, process arguments, adjacent envelope files, ordinary run, and state backup neither carry nor create the credential. +Local-signer calls use the source-locked hardened Lib administration client for +strict HTTP/1.1 JSON over the configured Unix socket. One fixed +`/v1/provider/operation` endpoint carries a closed internally tagged operation +and result plus exact provider instance, role, operation/correlation IDs, +absolute deadline, expected identity, capability, and contract version. The +validated binding supplies body, deadline, and per-client concurrency limits. +Transport success remains sealed and semantically untrusted until independent +Step 135 verification; cancellation does not prove the signer had no effect, +and signing is never publication. No TCP, browser origin, or child process is +part of this boundary. + 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/local_signer_transport.v1.json b/contracts/services_hardening/local_signer_transport.v1.json @@ -0,0 +1,81 @@ +{ + "schema": "radroots.myc.local-signer-transport", + "schema_version": 1, + "contract_version": 1, + "transport": "http_1_1_json_over_unix_domain_socket", + "shared_transport": "radroots_service_host::AdminClient", + "endpoint": "/v1/provider/operation", + "method": "POST", + "outer_admin_contract_version": 1, + "request_fields": [ + "contract_version", + "provider_instance", + "role", + "operation_id", + "correlation_id", + "absolute_deadline_unix_ms", + "expected_identity", + "capability", + "input" + ], + "response_fields": [ + "contract_version", + "provider_instance", + "role", + "operation_id", + "correlation_id", + "absolute_deadline_unix_ms", + "expected_identity", + "capability", + "result" + ], + "tagged_operations": [ + "describe", + "public_identity", + "sign_event", + "nip04_encrypt", + "nip04_decrypt", + "nip44_encrypt", + "nip44_decrypt" + ], + "operation_shapes": { + "describe": { + "input_fields": [], + "result_fields": ["public_identity", "protocol_version", "capabilities", "maximum_request_bytes"] + }, + "public_identity": { + "input_fields": [], + "result_fields": ["public_identity"] + }, + "sign_event": { + "input_fields": ["payload_hex"], + "result_fields": ["payload_hex"] + }, + "nip04_encrypt": { + "input_fields": ["peer", "payload_hex"], + "result_fields": ["payload_hex"] + }, + "nip04_decrypt": { + "input_fields": ["peer", "payload_hex"], + "result_fields": ["payload_hex"] + }, + "nip44_encrypt": { + "input_fields": ["peer", "version", "payload_hex"], + "result_fields": ["version", "payload_hex"] + }, + "nip44_decrypt": { + "input_fields": ["peer", "version", "payload_hex"], + "result_fields": ["version", "payload_hex"] + } + }, + "protected_payload_encoding": "lowercase_hex", + "limits_source": "validated_provider_binding", + "concurrency_scope": "per_client", + "semantic_success_trusted": false, + "semantic_verification_owner": "step-135", + "cancellation_proves_no_effect": false, + "signing_is_publication": false, + "tcp_allowed": false, + "browser_origin_allowed": false, + "child_process_allowed": false +} diff --git a/radroots.lib.source-lock.v1.toml b/radroots.lib.source-lock.v1.toml @@ -6,4 +6,4 @@ workspace_catalog_sha256 = "deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e version = "0.1.0-alpha" source_archive_sha256 = "975474804e6358b9228981add0a23181dbdd1afddf5ae12579c82220876bc379" lockfile = "Cargo.lock" -lockfile_sha256 = "8dfe6eb163aafac545e3e23110f4ccd0822a41dbbac8df441a5cedc85e75cce4" +lockfile_sha256 = "aac12052c5d06d73f71cefc5fcf2c1908ca7050adfebe477a5e670b055994a41" diff --git a/src/lib.rs b/src/lib.rs @@ -5,6 +5,7 @@ mod config_v1; mod provider_contract; mod provider_credential; mod provider_envelope; +mod provider_local_signer; mod runtime_context; mod state_catalog; mod state_connection; @@ -49,6 +50,11 @@ pub use provider_envelope::{ MycEncryptedIdentityProvisioningMaterial, MycWrappingCredential, open_myc_encrypted_identity, provision_myc_encrypted_identity, }; +pub use provider_local_signer::{ + MYC_LOCAL_SIGNER_ENDPOINT, MYC_LOCAL_SIGNER_TRANSPORT_CONTRACT_VERSION, MycLocalSignerClient, + MycLocalSignerTransportError, MycLocalSignerTransportErrorKind, + MycLocalSignerUntrustedResponse, +}; 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 @@ -425,6 +425,14 @@ impl MycProviderBinding { } } + #[cfg(any(target_os = "linux", target_os = "macos"))] + pub(crate) fn local_signer_socket_path(&self) -> Option<&std::path::Path> { + match &self.location { + ProviderLocation::LocalSigner { socket_path, .. } => Some(socket_path), + ProviderLocation::EncryptedFile { .. } => None, + } + } + fn location_is_valid(&self) -> bool { match &self.location { ProviderLocation::EncryptedFile { diff --git a/src/provider_local_signer.rs b/src/provider_local_signer.rs @@ -0,0 +1,1026 @@ +//! Strict bounded local-signer transport over the hardened Lib admin client. + +use core::fmt; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use core::time::Duration; +use std::error::Error; + +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; +use zeroize::Zeroizing; + +#[cfg(all(test, not(any(target_os = "linux", target_os = "macos"))))] +use crate::MycProviderNip44Version; +use crate::{ + MYC_PROVIDER_OUTPUT_MAX_BYTES, MycProviderBinding, MycProviderCapability, + MycProviderInstanceId, MycProviderOperation, MycProviderRole, +}; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use crate::{MycProviderKind, MycProviderNip44Version, MycProviderPublicIdentity}; + +/// Exact local-signer wire contract version. +pub const MYC_LOCAL_SIGNER_TRANSPORT_CONTRACT_VERSION: u32 = 1; +/// The one fixed local-signer provider endpoint. +pub const MYC_LOCAL_SIGNER_ENDPOINT: &str = "/v1/provider/operation"; + +const PROTECTED_WIRE_HEX_MAX_UTF8_BYTES: usize = MYC_PROVIDER_OUTPUT_MAX_BYTES * 2; + +/// Stable source-free local-signer transport failure classification. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycLocalSignerTransportErrorKind { + InvalidBinding, + InvalidOperation, + InvalidLimits, + RequestEncoding, + RequestLimit, + Transport, + Deadline, + Response, + RemoteFailure, + UnsupportedPlatform, +} + +impl MycLocalSignerTransportErrorKind { + /// Returns the stable machine-facing safe code. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::InvalidBinding => "local_signer_binding_invalid", + Self::InvalidOperation => "local_signer_operation_invalid", + Self::InvalidLimits => "local_signer_limits_invalid", + Self::RequestEncoding => "local_signer_request_encoding_failed", + Self::RequestLimit => "local_signer_request_limit_exceeded", + Self::Transport => "local_signer_transport_failed", + Self::Deadline => "local_signer_deadline_exceeded", + Self::Response => "local_signer_response_invalid", + Self::RemoteFailure => "local_signer_remote_failed", + Self::UnsupportedPlatform => "local_signer_platform_unsupported", + } + } + + const fn message(self) -> &'static str { + match self { + Self::InvalidBinding => "local signer binding is invalid", + Self::InvalidOperation => "local signer operation is invalid", + Self::InvalidLimits => "local signer limits are invalid", + Self::RequestEncoding => "local signer request encoding failed", + Self::RequestLimit => "local signer request exceeds its bound", + Self::Transport => "local signer transport failed", + Self::Deadline => "local signer deadline elapsed", + Self::Response => "local signer response is invalid", + Self::RemoteFailure => "local signer returned a failure", + Self::UnsupportedPlatform => "local signer transport is unsupported", + } + } +} + +/// One source-free local-signer transport failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct MycLocalSignerTransportError { + kind: MycLocalSignerTransportErrorKind, +} + +impl MycLocalSignerTransportError { + /// Returns the stable failure kind. + #[must_use] + pub const fn kind(self) -> MycLocalSignerTransportErrorKind { + 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 MycLocalSignerTransportError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycLocalSignerTransportError") + .field("kind", &self.kind) + .finish() + } +} + +impl fmt::Display for MycLocalSignerTransportError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.kind.message()) + } +} + +impl Error for MycLocalSignerTransportError {} + +const fn transport_error(kind: MycLocalSignerTransportErrorKind) -> MycLocalSignerTransportError { + MycLocalSignerTransportError { kind } +} + +/// A strictly decoded but semantically untrusted local-signer success. +/// +/// Step 135 independently verifies every binding and operation result before +/// any caller may treat this value as usable. Cancellation or timeout does not +/// prove that the external signer performed no operation, and this value never +/// represents publication. +pub struct MycLocalSignerUntrustedResponse { + #[cfg(any(target_os = "linux", target_os = "macos"))] + outer_correlation_id: radroots_service_host::AdminCorrelationId, + response: LocalSignerResponse, +} + +impl fmt::Debug for MycLocalSignerUntrustedResponse { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycLocalSignerUntrustedResponse") + .field("correlation_id", &{ + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + if self.outer_correlation_id.as_str().is_empty() { + "[invalid]" + } else { + "[redacted]" + } + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + "[redacted]" + } + }) + .field("provider_instance", &self.response.provider_instance) + .field("role", &self.response.role) + .field("capability", &self.response.capability) + .field("result", &"[redacted]") + .finish() + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +/// Bounded client for one configured local-signer Unix socket. +pub struct MycLocalSignerClient { + transport: radroots_service_host::AdminClient, + target: radroots_service_host::AdminClientTarget, + permits: tokio::sync::Semaphore, + request_deadline: Duration, + role: MycProviderRole, + instance: MycProviderInstanceId, + expected_identity: MycProviderPublicIdentity, +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +/// Unsupported-target local-signer client placeholder. +pub struct MycLocalSignerClient { + _private: (), +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl MycLocalSignerClient { + /// Constructs a client from one validated local-signer provider binding. + pub fn new(binding: &MycProviderBinding) -> Result<Self, MycLocalSignerTransportError> { + use radroots_service_host::{AdminClient, AdminClientTarget, AdminTransportLimits}; + + if binding.kind() != MycProviderKind::LocalSigner { + return Err(transport_error( + MycLocalSignerTransportErrorKind::InvalidBinding, + )); + } + let socket_path = binding + .local_signer_socket_path() + .ok_or_else(|| transport_error(MycLocalSignerTransportErrorKind::InvalidBinding))?; + let limits = binding + .local_signer_limits() + .ok_or_else(|| transport_error(MycLocalSignerTransportErrorKind::InvalidBinding))?; + let request_deadline = Duration::from_millis(limits.request_deadline_ms()); + let mut transport_values = AdminTransportLimits::DEFAULT.values(); + transport_values.request_body_utf8_bytes = u32::try_from(limits.request_max_bytes()) + .map_err(|_| transport_error(MycLocalSignerTransportErrorKind::InvalidLimits))?; + transport_values.response_body_utf8_bytes = u32::try_from(limits.response_max_bytes()) + .map_err(|_| transport_error(MycLocalSignerTransportErrorKind::InvalidLimits))?; + transport_values.concurrent_connections = limits.concurrency(); + transport_values.request_deadline = request_deadline; + let transport_limits = AdminTransportLimits::new(transport_values) + .map_err(|_| transport_error(MycLocalSignerTransportErrorKind::InvalidLimits))?; + let transport = AdminClient::new(socket_path, transport_limits) + .map_err(|_| transport_error(MycLocalSignerTransportErrorKind::InvalidBinding))?; + let target = AdminClientTarget::new(MYC_LOCAL_SIGNER_ENDPOINT) + .map_err(|_| transport_error(MycLocalSignerTransportErrorKind::InvalidBinding))?; + Ok(Self { + transport, + target, + permits: tokio::sync::Semaphore::new(limits.concurrency() as usize), + request_deadline, + role: binding.role(), + instance: binding.instance(), + expected_identity: binding.expected_identity().clone(), + }) + } + + /// Executes one already-bound operation and returns only untrusted output. + pub async fn execute( + &self, + operation: &MycProviderOperation, + ) -> Result<MycLocalSignerUntrustedResponse, MycLocalSignerTransportError> { + use radroots_service_host::{AdminCorrelationId, AdminOperationId}; + + self.validate_operation(operation)?; + let request = LocalSignerRequest::from_operation(operation)?; + let operation_id = AdminOperationId::new(hex::encode(operation.operation_id().as_bytes())) + .map_err(|_| transport_error(MycLocalSignerTransportErrorKind::InvalidOperation))?; + let correlation_id = + AdminCorrelationId::new(hex::encode(operation.correlation_id().as_bytes())) + .map_err(|_| transport_error(MycLocalSignerTransportErrorKind::InvalidOperation))?; + let response = tokio::time::timeout(self.request_deadline, async { + let _permit = self + .permits + .acquire() + .await + .map_err(|_| transport_error(MycLocalSignerTransportErrorKind::Transport))?; + self.transport + .mutate::<_, LocalSignerResponse>( + &self.target, + operation_id, + Some(correlation_id.clone()), + request, + ) + .await + .map_err(map_admin_error) + }) + .await + .map_err(|_| transport_error(MycLocalSignerTransportErrorKind::Deadline))??; + Ok(MycLocalSignerUntrustedResponse { + outer_correlation_id: response.correlation_id().clone(), + response: response.into_result(), + }) + } + + fn validate_operation( + &self, + operation: &MycProviderOperation, + ) -> Result<(), MycLocalSignerTransportError> { + if operation.provider() != MycProviderKind::LocalSigner + || operation.role() != self.role + || operation.instance() != self.instance + || operation.expected_identity() != &self.expected_identity + { + return Err(transport_error( + MycLocalSignerTransportErrorKind::InvalidOperation, + )); + } + Ok(()) + } +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +impl MycLocalSignerClient { + /// Fails closed before any transport work on unsupported targets. + pub fn new(_binding: &MycProviderBinding) -> Result<Self, MycLocalSignerTransportError> { + Err(transport_error( + MycLocalSignerTransportErrorKind::UnsupportedPlatform, + )) + } + + /// Fails closed before any transport work on unsupported targets. + pub async fn execute( + &self, + _operation: &MycProviderOperation, + ) -> Result<MycLocalSignerUntrustedResponse, MycLocalSignerTransportError> { + Err(transport_error( + MycLocalSignerTransportErrorKind::UnsupportedPlatform, + )) + } +} + +impl fmt::Debug for MycLocalSignerClient { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycLocalSignerClient") + .field("socket_path", &"[redacted]") + .finish_non_exhaustive() + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn map_admin_error(error: radroots_service_host::AdminClientError) -> MycLocalSignerTransportError { + use radroots_service_host::AdminClientErrorKind; + + let kind = match error.kind() { + AdminClientErrorKind::SocketPath => MycLocalSignerTransportErrorKind::InvalidBinding, + AdminClientErrorKind::RequestEncoding => MycLocalSignerTransportErrorKind::RequestEncoding, + AdminClientErrorKind::RequestLimit => MycLocalSignerTransportErrorKind::RequestLimit, + AdminClientErrorKind::Deadline => MycLocalSignerTransportErrorKind::Deadline, + AdminClientErrorKind::Connect | AdminClientErrorKind::Transport => { + MycLocalSignerTransportErrorKind::Transport + } + AdminClientErrorKind::QueryLimit + | AdminClientErrorKind::ResponseHeaders + | AdminClientErrorKind::ResponseLimit + | AdminClientErrorKind::ResponseContentType + | AdminClientErrorKind::ResponseHttpVersion + | AdminClientErrorKind::MalformedResponse + | AdminClientErrorKind::UnsupportedContractVersion => { + MycLocalSignerTransportErrorKind::Response + } + AdminClientErrorKind::ServerFailure => MycLocalSignerTransportErrorKind::RemoteFailure, + }; + transport_error(kind) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum WireRole { + Transport, + User, + Discovery, +} + +impl From<MycProviderRole> for WireRole { + fn from(role: MycProviderRole) -> Self { + match role { + MycProviderRole::Transport => Self::Transport, + MycProviderRole::User => Self::User, + MycProviderRole::Discovery => Self::Discovery, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum WireProviderInstance { + Transport, + User, + Discovery, +} + +impl From<MycProviderInstanceId> for WireProviderInstance { + fn from(instance: MycProviderInstanceId) -> Self { + match instance { + MycProviderInstanceId::Transport => Self::Transport, + MycProviderInstanceId::User => Self::User, + MycProviderInstanceId::Discovery => Self::Discovery, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum WireCapability { + Describe, + PublicIdentity, + SignEvent, + Nip04Encrypt, + Nip04Decrypt, + Nip44Encrypt, + Nip44Decrypt, +} + +impl From<MycProviderCapability> for WireCapability { + fn from(capability: MycProviderCapability) -> Self { + match capability { + MycProviderCapability::Describe => Self::Describe, + MycProviderCapability::PublicIdentity => Self::PublicIdentity, + MycProviderCapability::SignEvent => Self::SignEvent, + MycProviderCapability::Nip04Encrypt => Self::Nip04Encrypt, + MycProviderCapability::Nip04Decrypt => Self::Nip04Decrypt, + MycProviderCapability::Nip44Encrypt => Self::Nip44Encrypt, + MycProviderCapability::Nip44Decrypt => Self::Nip44Decrypt, + } + } +} + +#[cfg(any(test, target_os = "linux", target_os = "macos"))] +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct LocalSignerRequest { + contract_version: u32, + provider_instance: WireProviderInstance, + role: WireRole, + operation_id: String, + correlation_id: String, + absolute_deadline_unix_ms: u64, + expected_identity: String, + capability: WireCapability, + input: WireProviderInput, +} + +#[cfg(any(test, target_os = "linux", target_os = "macos"))] +impl LocalSignerRequest { + fn from_operation( + operation: &MycProviderOperation, + ) -> Result<Self, MycLocalSignerTransportError> { + Ok(Self { + contract_version: MYC_LOCAL_SIGNER_TRANSPORT_CONTRACT_VERSION, + provider_instance: operation.instance().into(), + role: operation.role().into(), + operation_id: hex::encode(operation.operation_id().as_bytes()), + correlation_id: hex::encode(operation.correlation_id().as_bytes()), + absolute_deadline_unix_ms: operation.deadline().get(), + expected_identity: operation.expected_identity().as_hex().to_owned(), + capability: operation.input().capability().into(), + input: WireProviderInput::from_operation(operation)?, + }) + } +} + +#[cfg(any(test, target_os = "linux", target_os = "macos"))] +#[derive(Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +enum WireProviderInput { + Describe, + PublicIdentity, + SignEvent { + payload_hex: ProtectedWireHex, + }, + Nip04Encrypt { + peer: String, + payload_hex: ProtectedWireHex, + }, + Nip04Decrypt { + peer: String, + payload_hex: ProtectedWireHex, + }, + Nip44Encrypt { + peer: String, + version: u8, + payload_hex: ProtectedWireHex, + }, + Nip44Decrypt { + peer: String, + version: u8, + payload_hex: ProtectedWireHex, + }, +} + +#[cfg(any(test, target_os = "linux", target_os = "macos"))] +impl WireProviderInput { + fn from_operation( + operation: &MycProviderOperation, + ) -> Result<Self, MycLocalSignerTransportError> { + let input = operation.input(); + let protected = || { + input + .bytes() + .map(ProtectedWireHex::from_bytes) + .ok_or_else(|| transport_error(MycLocalSignerTransportErrorKind::InvalidOperation)) + }; + let peer = || { + input + .peer() + .map(|identity| identity.as_hex().to_owned()) + .ok_or_else(|| transport_error(MycLocalSignerTransportErrorKind::InvalidOperation)) + }; + let version = || { + input + .nip44_version() + .map(MycProviderNip44Version::as_u8) + .ok_or_else(|| transport_error(MycLocalSignerTransportErrorKind::InvalidOperation)) + }; + match input.capability() { + MycProviderCapability::Describe => Ok(Self::Describe), + MycProviderCapability::PublicIdentity => Ok(Self::PublicIdentity), + MycProviderCapability::SignEvent => Ok(Self::SignEvent { + payload_hex: protected()?, + }), + MycProviderCapability::Nip04Encrypt => Ok(Self::Nip04Encrypt { + peer: peer()?, + payload_hex: protected()?, + }), + MycProviderCapability::Nip04Decrypt => Ok(Self::Nip04Decrypt { + peer: peer()?, + payload_hex: protected()?, + }), + MycProviderCapability::Nip44Encrypt => Ok(Self::Nip44Encrypt { + peer: peer()?, + version: version()?, + payload_hex: protected()?, + }), + MycProviderCapability::Nip44Decrypt => Ok(Self::Nip44Decrypt { + peer: peer()?, + version: version()?, + payload_hex: protected()?, + }), + } + } +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct LocalSignerResponse { + contract_version: u32, + provider_instance: WireProviderInstance, + role: WireRole, + operation_id: String, + correlation_id: String, + absolute_deadline_unix_ms: u64, + expected_identity: String, + capability: WireCapability, + result: WireProviderResult, +} + +#[derive(Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +enum WireProviderResult { + Describe { + public_identity: String, + protocol_version: u32, + capabilities: Vec<WireCapability>, + maximum_request_bytes: u64, + }, + PublicIdentity { + public_identity: String, + }, + SignEvent { + payload_hex: ProtectedWireHex, + }, + Nip04Encrypt { + payload_hex: ProtectedWireHex, + }, + Nip04Decrypt { + payload_hex: ProtectedWireHex, + }, + Nip44Encrypt { + version: u8, + payload_hex: ProtectedWireHex, + }, + Nip44Decrypt { + version: u8, + payload_hex: ProtectedWireHex, + }, +} + +struct ProtectedWireHex(Zeroizing<String>); + +impl ProtectedWireHex { + #[cfg(any(test, target_os = "linux", target_os = "macos"))] + fn from_bytes(bytes: &[u8]) -> Self { + Self(Zeroizing::new(hex::encode(bytes))) + } + + fn from_string(value: String) -> Result<Self, MycLocalSignerTransportError> { + if value.len() > PROTECTED_WIRE_HEX_MAX_UTF8_BYTES + || !value.len().is_multiple_of(2) + || value + .bytes() + .any(|byte| !matches!(byte, b'0'..=b'9' | b'a'..=b'f')) + { + return Err(transport_error(MycLocalSignerTransportErrorKind::Response)); + } + Ok(Self(Zeroizing::new(value))) + } +} + +impl Serialize for ProtectedWireHex { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + serializer.serialize_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for ProtectedWireHex { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + struct Visitor; + + impl de::Visitor<'_> for Visitor { + type Value = ProtectedWireHex; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("bounded lowercase even-length hexadecimal") + } + + fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> + where + E: de::Error, + { + if value.len() > PROTECTED_WIRE_HEX_MAX_UTF8_BYTES { + return Err(E::custom("protected wire value exceeds its bound")); + } + ProtectedWireHex::from_string(value.to_owned()).map_err(E::custom) + } + + fn visit_string<E>(self, value: String) -> Result<Self::Value, E> + where + E: de::Error, + { + ProtectedWireHex::from_string(value).map_err(E::custom) + } + } + + deserializer.deserialize_string(Visitor) + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use crate::{ + MycConfigProfile, MycProviderCorrelationId, MycProviderDeadlineUnixMs, + MycProviderOperationId, MycProviderOperationInput, MycProviderPublicIdentity, + parse_myc_config_v1, + }; + + use super::*; + + const CONFIG: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); + + fn user_binding(socket: &Path, deadline_ms: u64, concurrency: u32) -> MycProviderBinding { + let source = CONFIG + .replace( + "/run/radroots/services/myc/primary/user-signer.sock", + socket.to_str().expect("UTF-8 test socket"), + ) + .replacen( + "request_deadline_ms = 15000", + &format!("request_deadline_ms = {deadline_ms}"), + 1, + ) + .replacen( + "concurrency = 32", + &format!("concurrency = {concurrency}"), + 1, + ); + parse_myc_config_v1(source.as_bytes(), MycConfigProfile::RepoLocal) + .expect("test configuration") + .provider_contract() + .binding(MycProviderRole::User) + .expect("user binding") + .clone() + } + + fn operation( + binding: &MycProviderBinding, + seed: u8, + input: MycProviderOperationInput, + ) -> MycProviderOperation { + MycProviderOperation::new( + binding, + MycProviderOperationId::from_bytes([seed; 32]), + MycProviderCorrelationId::from_bytes([seed.wrapping_add(1); 32]), + MycProviderDeadlineUnixMs::new(2_000_000_000_000).expect("deadline"), + input, + ) + .expect("operation") + } + + #[test] + fn protected_wire_hex_is_bounded_canonical_and_redacted() { + let value = ProtectedWireHex::from_bytes(b"protected-value"); + assert_eq!( + serde_json::to_string(&value).unwrap(), + format!("\"{}\"", hex::encode(b"protected-value")) + ); + for invalid in ["a", "AA", "0g"] { + assert!(serde_json::from_str::<ProtectedWireHex>(&format!("\"{invalid}\"")).is_err()); + } + let oversized = format!("\"{}\"", "a".repeat(PROTECTED_WIRE_HEX_MAX_UTF8_BYTES + 2)); + assert!(serde_json::from_str::<ProtectedWireHex>(&oversized).is_err()); + } + + #[test] + fn transport_errors_are_source_free_and_fixed() { + for kind in [ + MycLocalSignerTransportErrorKind::InvalidBinding, + MycLocalSignerTransportErrorKind::InvalidOperation, + MycLocalSignerTransportErrorKind::InvalidLimits, + MycLocalSignerTransportErrorKind::RequestEncoding, + MycLocalSignerTransportErrorKind::RequestLimit, + MycLocalSignerTransportErrorKind::Transport, + MycLocalSignerTransportErrorKind::Deadline, + MycLocalSignerTransportErrorKind::Response, + MycLocalSignerTransportErrorKind::RemoteFailure, + MycLocalSignerTransportErrorKind::UnsupportedPlatform, + ] { + let error = transport_error(kind); + assert_eq!(error.kind(), kind); + assert!(!error.code().is_empty()); + assert!(error.source().is_none()); + } + } + + #[test] + fn every_operation_has_one_closed_internal_tag_and_complete_binding() { + let binding = user_binding(Path::new("/run/test-signer.sock"), 15_000, 1); + let peer = MycProviderPublicIdentity::new( + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .expect("peer"); + let inputs = [ + MycProviderOperationInput::describe(), + MycProviderOperationInput::public_identity(), + MycProviderOperationInput::sign_event(b"unsigned-event").expect("sign"), + MycProviderOperationInput::nip04_encrypt(peer.clone(), b"plaintext") + .expect("nip04 encrypt"), + MycProviderOperationInput::nip04_decrypt(peer.clone(), b"ciphertext") + .expect("nip04 decrypt"), + MycProviderOperationInput::nip44_encrypt( + peer.clone(), + MycProviderNip44Version::V2, + b"plaintext", + ) + .expect("nip44 encrypt"), + MycProviderOperationInput::nip44_decrypt( + peer, + MycProviderNip44Version::V2, + b"ciphertext", + ) + .expect("nip44 decrypt"), + ]; + let expected = [ + "describe", + "public_identity", + "sign_event", + "nip04_encrypt", + "nip04_decrypt", + "nip44_encrypt", + "nip44_decrypt", + ]; + for (index, (input, expected)) in inputs.into_iter().zip(expected).enumerate() { + let operation = operation(&binding, index as u8 + 1, input); + let request = LocalSignerRequest::from_operation(&operation).expect("wire request"); + let value = serde_json::to_value(request).expect("request JSON"); + assert_eq!(value["contract_version"], 1); + assert_eq!(value["provider_instance"], "user"); + assert_eq!(value["role"], "user"); + assert_eq!(value["capability"], expected); + assert_eq!(value["input"]["type"], expected); + assert_eq!(value["operation_id"].as_str().map(str::len), Some(64)); + assert_eq!(value["correlation_id"].as_str().map(str::len), Some(64)); + assert_eq!(value["absolute_deadline_unix_ms"], 2_000_000_000_000_u64); + assert_eq!( + value["expected_identity"], + binding.expected_identity().as_hex() + ); + } + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + mod native { + use core::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + use radroots_service_host::{ + AdminHttpMethod, AdminMutationRequest, AdminRouter, AdminServer, AdminTransportLimits, + CancellationToken, EntropyError, EntropySource, UnixAdminSocketBinding, + UnixAdminSocketWriterAuthority, + }; + + use super::*; + + struct FixedEntropy; + + impl EntropySource for FixedEntropy { + fn fill_bytes(&self, destination: &mut [u8]) -> Result<(), EntropyError> { + destination.fill(7); + Ok(()) + } + } + + fn runtime_directory() -> tempfile::TempDir { + tempfile::Builder::new() + .prefix("myc-ls-") + .tempdir_in("/tmp") + .expect("short runtime directory") + } + + async fn start_server<F, Fut>( + directory: &tempfile::TempDir, + handler: F, + ) -> ( + std::path::PathBuf, + CancellationToken, + tokio::task::JoinHandle<()>, + ) + where + F: Fn(radroots_service_host::AdminRequest) -> Fut + Send + Sync + 'static, + Fut: core::future::Future<Output = radroots_service_host::AdminRouteOutcome> + + Send + + 'static, + { + let socket = directory.path().join("user-signer.sock"); + let authority = UnixAdminSocketWriterAuthority::acquire(directory.path()) + .expect("writer authority"); + let binding = UnixAdminSocketBinding::bind(authority, &socket) + .await + .expect("socket binding"); + let mut router = AdminRouter::new(); + router + .route(AdminHttpMethod::Post, MYC_LOCAL_SIGNER_ENDPOINT, handler) + .expect("provider route"); + let server = AdminServer::new(router, AdminTransportLimits::DEFAULT, FixedEntropy) + .expect("admin server"); + let cancellation = CancellationToken::new(); + let server_cancellation = cancellation.clone(); + let task = tokio::spawn(async move { + server + .serve(binding, server_cancellation) + .await + .expect("serve local signer"); + }); + (socket, cancellation, task) + } + + fn response_from(request: LocalSignerRequest) -> LocalSignerResponse { + LocalSignerResponse { + contract_version: request.contract_version, + provider_instance: request.provider_instance, + role: request.role, + operation_id: request.operation_id, + correlation_id: request.correlation_id, + absolute_deadline_unix_ms: request.absolute_deadline_unix_ms, + expected_identity: request.expected_identity.clone(), + capability: request.capability, + result: WireProviderResult::PublicIdentity { + public_identity: request.expected_identity, + }, + } + } + + #[tokio::test] + async fn hardened_admin_round_trip_carries_exact_bound_operation() { + let directory = runtime_directory(); + let (socket, cancellation, task) = start_server(&directory, |request| async move { + let envelope = request + .decode_json::<AdminMutationRequest<LocalSignerRequest>>() + .expect("strict mutation request"); + let response = response_from(envelope.into_request()); + request.success(&response).expect("provider response") + }) + .await; + let binding = user_binding(&socket, 1_000, 2); + let operation = operation(&binding, 4, MycProviderOperationInput::public_identity()); + let client = MycLocalSignerClient::new(&binding).expect("client"); + let response = client.execute(&operation).await.expect("transport success"); + assert_eq!( + response.outer_correlation_id.as_str(), + hex::encode(operation.correlation_id().as_bytes()) + ); + assert_eq!(response.response.contract_version, 1); + assert_eq!( + response.response.provider_instance, + WireProviderInstance::User + ); + assert_eq!(response.response.role, WireRole::User); + assert_eq!(response.response.capability, WireCapability::PublicIdentity); + assert_eq!( + format!("{response:?}"), + "MycLocalSignerUntrustedResponse { correlation_id: \"[redacted]\", provider_instance: User, role: User, capability: PublicIdentity, result: \"[redacted]\" }" + ); + cancellation.cancel(); + task.await.expect("server task"); + } + + #[tokio::test] + async fn malformed_success_shape_is_normalized_without_raw_response_exposure() { + let directory = runtime_directory(); + let (socket, cancellation, task) = start_server(&directory, |request| async move { + let _: AdminMutationRequest<LocalSignerRequest> = + request.decode_json().expect("strict mutation request"); + request + .success(&serde_json::json!({"unexpected": "protected-response"})) + .expect("malformed semantic success") + }) + .await; + let binding = user_binding(&socket, 1_000, 2); + let operation = operation(&binding, 5, MycProviderOperationInput::public_identity()); + let client = MycLocalSignerClient::new(&binding).expect("client"); + let error = client + .execute(&operation) + .await + .expect_err("malformed response"); + assert_eq!(error.kind(), MycLocalSignerTransportErrorKind::Response); + assert!(!format!("{error} {error:?}").contains("protected-response")); + cancellation.cancel(); + task.await.expect("server task"); + } + + #[tokio::test] + async fn configured_deadline_and_per_client_concurrency_are_enforced() { + let directory = runtime_directory(); + let active = Arc::new(AtomicUsize::new(0)); + let maximum = Arc::new(AtomicUsize::new(0)); + let handler_active = Arc::clone(&active); + let handler_maximum = Arc::clone(&maximum); + let (socket, cancellation, task) = start_server(&directory, move |request| { + let active = Arc::clone(&handler_active); + let maximum = Arc::clone(&handler_maximum); + async move { + let current = active.fetch_add(1, Ordering::SeqCst) + 1; + maximum.fetch_max(current, Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(30)).await; + active.fetch_sub(1, Ordering::SeqCst); + let envelope = request + .decode_json::<AdminMutationRequest<LocalSignerRequest>>() + .expect("strict request"); + request + .success(&response_from(envelope.into_request())) + .expect("response") + } + }) + .await; + let binding = user_binding(&socket, 500, 1); + let client = Arc::new(MycLocalSignerClient::new(&binding).expect("client")); + let first = operation(&binding, 1, MycProviderOperationInput::public_identity()); + let second = operation(&binding, 2, MycProviderOperationInput::public_identity()); + let first_client = Arc::clone(&client); + let second_client = Arc::clone(&client); + let (first_result, second_result) = tokio::join!( + async move { first_client.execute(&first).await }, + async move { second_client.execute(&second).await } + ); + first_result.expect("first response"); + second_result.expect("second response"); + assert_eq!(maximum.load(Ordering::SeqCst), 1); + cancellation.cancel(); + task.await.expect("server task"); + + let directory = runtime_directory(); + let (socket, cancellation, task) = start_server(&directory, |request| async move { + tokio::time::sleep(Duration::from_millis(75)).await; + let envelope = request + .decode_json::<AdminMutationRequest<LocalSignerRequest>>() + .expect("strict request"); + request + .success(&response_from(envelope.into_request())) + .expect("response") + }) + .await; + let binding = user_binding(&socket, 10, 1); + let client = MycLocalSignerClient::new(&binding).expect("deadline client"); + let operation = operation(&binding, 3, MycProviderOperationInput::public_identity()); + assert_eq!( + client + .execute(&operation) + .await + .expect_err("deadline") + .kind(), + MycLocalSignerTransportErrorKind::Deadline + ); + cancellation.cancel(); + task.await.expect("server task"); + } + + #[tokio::test] + async fn wrong_provider_and_request_limit_fail_before_transport_success() { + let socket = Path::new("/run/test-signer.sock"); + let mut source = CONFIG + .replace( + "/run/radroots/services/myc/primary/user-signer.sock", + socket.to_str().expect("UTF-8 socket"), + ) + .replacen("request_max_bytes = 65536", "request_max_bytes = 1", 1); + source = source.replacen("concurrency = 32", "concurrency = 1", 1); + let tiny = parse_myc_config_v1(source.as_bytes(), MycConfigProfile::RepoLocal) + .expect("tiny configuration") + .provider_contract() + .binding(MycProviderRole::User) + .expect("tiny binding") + .clone(); + let client = MycLocalSignerClient::new(&tiny).expect("tiny client"); + assert_eq!(client.transport.limits().request_body_utf8_bytes(), 1); + assert_eq!( + client.transport.limits().response_body_utf8_bytes(), + 1_048_576 + ); + assert_eq!(client.transport.limits().concurrent_connections(), 1); + assert_eq!( + client.transport.limits().request_deadline(), + Duration::from_secs(15) + ); + assert_eq!(client.permits.available_permits(), 1); + let tiny_operation = operation(&tiny, 9, MycProviderOperationInput::public_identity()); + assert_eq!( + client + .execute(&tiny_operation) + .await + .expect_err("request bound") + .kind(), + MycLocalSignerTransportErrorKind::RequestLimit + ); + + let encrypted = parse_myc_config_v1(CONFIG.as_bytes(), MycConfigProfile::RepoLocal) + .expect("configuration") + .provider_contract() + .binding(MycProviderRole::Transport) + .expect("encrypted binding") + .clone(); + assert_eq!( + MycLocalSignerClient::new(&encrypted) + .expect_err("wrong provider") + .kind(), + MycLocalSignerTransportErrorKind::InvalidBinding + ); + + let mismatched = + operation(&encrypted, 10, MycProviderOperationInput::public_identity()); + assert_eq!( + client + .execute(&mismatched) + .await + .expect_err("mismatched operation") + .kind(), + MycLocalSignerTransportErrorKind::InvalidOperation + ); + } + } +} diff --git a/tests/build_policy.rs b/tests/build_policy.rs @@ -34,6 +34,13 @@ fn shared_runtime_paths_is_exactly_pinned_to_the_source_locked_lib() { } #[test] +fn shared_service_host_is_exactly_pinned_to_the_source_locked_lib() { + assert!(MANIFEST.contains( + "radroots_service_host = { git = \"https://github.com/radrootslabs/lib\", rev = \"b44119fbac5985be8127ad1bf56d2950e6399427\", version = \"=0.1.0-alpha\" }" + )); +} + +#[test] fn shared_service_sqlite_is_exactly_pinned_to_the_source_locked_lib() { assert!(MANIFEST.contains( "radroots_service_sqlite = { git = \"https://github.com/radrootslabs/lib\", rev = \"b44119fbac5985be8127ad1bf56d2950e6399427\", version = \"=0.1.0-alpha\" }" diff --git a/tests/services_hardening_local_signer_transport.rs b/tests/services_hardening_local_signer_transport.rs @@ -0,0 +1,159 @@ +#![forbid(unsafe_code)] + +use serde_json::json; + +const CONTRACT: &str = + include_str!("../contracts/services_hardening/local_signer_transport.v1.json"); +const LIB_SOURCE: &str = include_str!("../src/lib.rs"); +const TRANSPORT_SOURCE: &str = include_str!("../src/provider_local_signer.rs"); +const PROVIDER_SOURCE: &str = include_str!("../src/provider_contract.rs"); +const CARGO_SOURCE: &str = include_str!("../Cargo.toml"); + +#[test] +fn machine_contract_freezes_the_complete_local_signer_transport() { + let actual: serde_json::Value = serde_json::from_str(CONTRACT).expect("contract JSON"); + assert_eq!( + actual, + json!({ + "schema": "radroots.myc.local-signer-transport", + "schema_version": 1, + "contract_version": 1, + "transport": "http_1_1_json_over_unix_domain_socket", + "shared_transport": "radroots_service_host::AdminClient", + "endpoint": "/v1/provider/operation", + "method": "POST", + "outer_admin_contract_version": 1, + "request_fields": [ + "contract_version", "provider_instance", "role", "operation_id", + "correlation_id", "absolute_deadline_unix_ms", "expected_identity", + "capability", "input" + ], + "response_fields": [ + "contract_version", "provider_instance", "role", "operation_id", + "correlation_id", "absolute_deadline_unix_ms", "expected_identity", + "capability", "result" + ], + "tagged_operations": [ + "describe", "public_identity", "sign_event", "nip04_encrypt", + "nip04_decrypt", "nip44_encrypt", "nip44_decrypt" + ], + "operation_shapes": { + "describe": { + "input_fields": [], + "result_fields": ["public_identity", "protocol_version", "capabilities", "maximum_request_bytes"] + }, + "public_identity": { + "input_fields": [], + "result_fields": ["public_identity"] + }, + "sign_event": { + "input_fields": ["payload_hex"], + "result_fields": ["payload_hex"] + }, + "nip04_encrypt": { + "input_fields": ["peer", "payload_hex"], + "result_fields": ["payload_hex"] + }, + "nip04_decrypt": { + "input_fields": ["peer", "payload_hex"], + "result_fields": ["payload_hex"] + }, + "nip44_encrypt": { + "input_fields": ["peer", "version", "payload_hex"], + "result_fields": ["version", "payload_hex"] + }, + "nip44_decrypt": { + "input_fields": ["peer", "version", "payload_hex"], + "result_fields": ["version", "payload_hex"] + } + }, + "protected_payload_encoding": "lowercase_hex", + "limits_source": "validated_provider_binding", + "concurrency_scope": "per_client", + "semantic_success_trusted": false, + "semantic_verification_owner": "step-135", + "cancellation_proves_no_effect": false, + "signing_is_publication": false, + "tcp_allowed": false, + "browser_origin_allowed": false, + "child_process_allowed": false + }) + ); +} + +#[test] +fn implementation_uses_only_the_hardened_fixed_unix_admin_boundary() { + for required in [ + "radroots_service_host = { git = \"https://github.com/radrootslabs/lib\", rev = \"b44119fbac5985be8127ad1bf56d2950e6399427\"", + "const MYC_LOCAL_SIGNER_ENDPOINT: &str = \"/v1/provider/operation\"", + "radroots_service_host::AdminClient", + ".mutate::<_, LocalSignerResponse>(", + "tokio::sync::Semaphore", + "tokio::time::timeout(self.request_deadline", + "#[serde(tag = \"type\", rename_all = \"snake_case\", deny_unknown_fields)]", + "MycLocalSignerUntrustedResponse", + ] { + assert!( + CARGO_SOURCE.contains(required) + || TRANSPORT_SOURCE.contains(required) + || LIB_SOURCE.contains(required), + "missing hardened transport boundary {required}" + ); + } + assert!(PROVIDER_SOURCE.contains("pub(crate) fn local_signer_socket_path")); +} + +#[test] +fn no_parallel_transport_provider_execution_or_success_trust_is_introduced() { + let production = TRANSPORT_SOURCE + .split("#[cfg(test)]") + .next() + .expect("production source"); + for forbidden in [ + "TcpStream", + "reqwest", + "hyper::", + "std::process", + "tokio::process", + "Command::new", + "UnixStream::connect", + "AdminServer", + "signing_is_publication = true", + "semantic_success_trusted = true", + "ServiceSqliteTransaction", + ] { + assert!( + !production.contains(forbidden), + "forbidden local-signer authority {forbidden}" + ); + } +} + +#[test] +fn public_boundary_is_sealed_redacted_and_dependency_free() { + for required in [ + "pub struct MycLocalSignerClient", + "pub struct MycLocalSignerUntrustedResponse", + "pub enum MycLocalSignerTransportErrorKind", + "pub struct MycLocalSignerTransportError", + "impl Error for MycLocalSignerTransportError {}", + ".field(\"result\", &\"[redacted]\")", + ] { + assert!(TRANSPORT_SOURCE.contains(required)); + } + for forbidden in [ + "pub transport:", + "pub permits:", + "pub response:", + "pub outer_correlation_id:", + "pub socket_path:", + "pub fn into_inner", + "pub fn raw_response", + "pub source:", + "radroots_service_host::AdminClientError", + ] { + assert!(!LIB_SOURCE.contains(forbidden)); + } + assert!(LIB_SOURCE.contains("mod provider_local_signer;")); + assert!(!LIB_SOURCE.contains("pub mod provider_local_signer")); +}