myc

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

commit 5f0bc868701ae6fe97b749d9924df66077c1e41e
parent e6899a7dfc29f036e2ae39acb7c88cec004ee500
Author: triesap <tyson@radroots.org>
Date:   Fri, 21 Aug 2026 21:54:28 +0000

provider: verify local signer results independently

Diffstat:
MAGENTS.md | 8++++++++
MCargo.lock | 1+
MCargo.toml | 1+
MREADME | 12++++++++++++
Mcontracts/services_hardening/local_signer_transport.v1.json | 8++++----
Acontracts/services_hardening/provider_verification.v1.json | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mradroots.lib.source-lock.v1.toml | 2+-
Msrc/lib.rs | 5+++++
Msrc/provider_contract.rs | 24++++++++++++++++++++++--
Msrc/provider_local_signer.rs | 91++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Asrc/provider_verification.rs | 1428+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/services_hardening_local_signer_transport.rs | 8++++----
Atests/services_hardening_provider_verification.rs | 149+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
13 files changed, 1763 insertions(+), 44 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -63,6 +63,14 @@ 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. +- Provider verification is frozen by + `contracts/services_hardening/provider_verification.v1.json`. It requires + injected observation time, rebinds the complete response to the configured + provider and original operation, verifies peer/direction/version and exact + protocol shapes, cryptographically verifies signed events, retains their + exact canonical bytes, and returns only a sealed redacted result. + Verification is not publication and performs no provider execution or + database mutation. - 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 @@ -1530,6 +1530,7 @@ name = "myc" version = "0.1.0" dependencies = [ "axum", + "base64", "chacha20poly1305", "clap", "futures-executor", diff --git a/Cargo.toml b/Cargo.toml @@ -31,6 +31,7 @@ service-host = [] [dependencies] axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio"] } +base64 = "0.22" chacha20poly1305 = "0.10" clap = { version = "4.5", features = ["derive"] } futures-executor = "0.3" diff --git a/README b/README @@ -119,6 +119,18 @@ 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. +Independent provider verification uses injected completion time and rejects a +late result before semantic exposure. It rebinds the configured role, +instance, identity, operation/correlation IDs, deadline, capability, NIP peer, +direction, and version. Describe/public-identity results must match the +configured identity and limits; signed Nostr events must preserve the exact +canonical unsigned fields, pass event-ID and Schnorr verification, and retain +the exact verified canonical response bytes; NIP-04 and NIP-44 ciphertext must +have canonical protocol shape and exact padding length where the plaintext is +known. NIP-44 v2 plaintext is admitted only through its exact 65,408-byte +implementation ceiling. Verified output remains sealed and redacted, and +neither verification nor signing constitutes publication. + 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 @@ -53,19 +53,19 @@ }, "nip04_encrypt": { "input_fields": ["peer", "payload_hex"], - "result_fields": ["payload_hex"] + "result_fields": ["peer", "payload_hex"] }, "nip04_decrypt": { "input_fields": ["peer", "payload_hex"], - "result_fields": ["payload_hex"] + "result_fields": ["peer", "payload_hex"] }, "nip44_encrypt": { "input_fields": ["peer", "version", "payload_hex"], - "result_fields": ["version", "payload_hex"] + "result_fields": ["peer", "version", "payload_hex"] }, "nip44_decrypt": { "input_fields": ["peer", "version", "payload_hex"], - "result_fields": ["version", "payload_hex"] + "result_fields": ["peer", "version", "payload_hex"] } }, "protected_payload_encoding": "lowercase_hex", diff --git a/contracts/services_hardening/provider_verification.v1.json b/contracts/services_hardening/provider_verification.v1.json @@ -0,0 +1,70 @@ +{ + "schema": "radroots.myc.provider-verification", + "schema_version": 1, + "provider_contract_version": 1, + "local_signer_transport_contract_version": 1, + "observation_time": { + "source": "injected", + "minimum_unix_ms": 1, + "maximum_unix_ms": 9223372036854775807 + }, + "verification_order": [ + "configured_binding", + "absolute_deadline", + "outer_correlation", + "response_envelope_binding", + "result_shape", + "semantic_result" + ], + "response_binding": [ + "contract_version", + "provider_instance", + "role", + "operation_id", + "correlation_id", + "absolute_deadline_unix_ms", + "expected_identity", + "capability" + ], + "describe": { + "identity": "exact_expected", + "protocol_version": 1, + "capabilities": "closed_unique_contains_role_requirements", + "maximum_request_bytes": "exact_configured_limit" + }, + "public_identity": "exact_expected", + "sign_event": [ + "canonical_unsigned_json", + "exact_unsigned_fields", + "exact_expected_author", + "computed_event_id", + "valid_schnorr_signature", + "canonical_signed_json", + "retain_exact_verified_bytes" + ], + "nip04": { + "direction": "exact_result_tag", + "peer": "exact_echo", + "ciphertext": "canonical_base64_aes_cbc_shape", + "encrypt_length": "exact_plaintext_padding_length", + "decrypt_length": "less_than_ciphertext_block_capacity" + }, + "nip44": { + "direction": "exact_result_tag", + "peer": "exact_echo", + "version": 2, + "plaintext_max_bytes": 65408, + "ciphertext": "canonical_base64_v2_shape", + "encrypt_length": "exact_v2_plaintext_padding_length", + "decrypt_length": "bounded_by_ciphertext_padding_capacity" + }, + "semantic_output_max_bytes": 1048576, + "verified_result_sealed": true, + "verified_result_serializable": false, + "safe_errors_source_free": true, + "late_result_usable": false, + "ambiguous_result_is_publication": false, + "signing_is_publication": false, + "database_transaction_allowed": false, + "provider_execution_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 = "aac12052c5d06d73f71cefc5fcf2c1908ca7050adfebe477a5e670b055994a41" +lockfile_sha256 = "07cc49b4ef22923aadf33b2d6f245f7c84db9df9bd666df9a838dd401d7e418c" diff --git a/src/lib.rs b/src/lib.rs @@ -6,6 +6,7 @@ mod provider_contract; mod provider_credential; mod provider_envelope; mod provider_local_signer; +mod provider_verification; mod runtime_context; mod state_catalog; mod state_connection; @@ -55,6 +56,10 @@ pub use provider_local_signer::{ MycLocalSignerTransportError, MycLocalSignerTransportErrorKind, MycLocalSignerUntrustedResponse, }; +pub use provider_verification::{ + MycProviderResponseObservedAtUnixMs, MycProviderVerificationError, + MycProviderVerificationErrorKind, MycVerifiedProviderResponse, +}; 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 @@ -15,6 +15,7 @@ pub const MYC_PROVIDER_CONTRACT_VERSION: u32 = 1; 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; +pub(crate) const MYC_PROVIDER_NIP44_PLAINTEXT_MAX_BYTES: usize = 65_536 - 128; /// Maximum configured local-signer request deadline. pub const MYC_PROVIDER_REQUEST_DEADLINE_MAX_MS: u64 = 30_000; /// Maximum configured local-signer request body. @@ -145,7 +146,7 @@ impl MycProviderCapabilitySet { Ok(Self(bits)) } - const fn for_role(role: MycProviderRole) -> Self { + pub(crate) const fn for_role(role: MycProviderRole) -> Self { let common = MycProviderCapability::Describe.bit() | MycProviderCapability::PublicIdentity.bit(); match role { @@ -739,11 +740,14 @@ impl MycProviderOperationInput { version: MycProviderNip44Version, plaintext: &[u8], ) -> Result<Self, MycProviderContractError> { + if plaintext.len() > MYC_PROVIDER_NIP44_PLAINTEXT_MAX_BYTES { + return Err(contract_error(MycProviderContractErrorKind::InvalidInput)); + } Ok(Self { kind: ProviderOperationInputKind::Nip44Encrypt { peer, version, - plaintext: copy_input(plaintext, true)?, + plaintext: copy_input(plaintext, false)?, }, }) } @@ -1155,6 +1159,22 @@ mod tests { encrypt.nip44_version().map(MycProviderNip44Version::as_u8), Some(2) ); + assert!( + MycProviderOperationInput::nip44_encrypt( + MycProviderPublicIdentity::new(&"2".repeat(64)).expect("peer"), + MycProviderNip44Version::V2, + &vec![0; MYC_PROVIDER_NIP44_PLAINTEXT_MAX_BYTES] + ) + .is_ok() + ); + assert!( + MycProviderOperationInput::nip44_encrypt( + MycProviderPublicIdentity::new(&"2".repeat(64)).expect("peer"), + MycProviderNip44Version::V2, + &vec![0; MYC_PROVIDER_NIP44_PLAINTEXT_MAX_BYTES + 1] + ) + .is_err() + ); assert!(MycUntrustedProviderOutput::new(&vec![0; MYC_PROVIDER_OUTPUT_MAX_BYTES]).is_ok()); assert!( diff --git a/src/provider_local_signer.rs b/src/provider_local_signer.rs @@ -121,29 +121,44 @@ const fn transport_error(kind: MycLocalSignerTransportErrorKind) -> MycLocalSign /// 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, + outer_correlation_id: Box<str>, response: LocalSignerResponse, } +impl MycLocalSignerUntrustedResponse { + #[cfg(test)] + pub(crate) fn from_parts(parts: LocalSignerUntrustedParts) -> Self { + Self { + outer_correlation_id: parts.outer_correlation_id, + response: parts.response, + } + } + + pub(crate) fn into_parts(self) -> LocalSignerUntrustedParts { + LocalSignerUntrustedParts { + outer_correlation_id: self.outer_correlation_id, + response: self.response, + } + } +} + +pub(crate) struct LocalSignerUntrustedParts { + pub(crate) outer_correlation_id: Box<str>, + pub(crate) 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")))] - { + .field( + "correlation_id", + &if self.outer_correlation_id.is_empty() { + "[invalid]" + } else { "[redacted]" - } - }) + }, + ) .field("provider_instance", &self.response.provider_instance) .field("role", &self.response.role) .field("capability", &self.response.capability) @@ -245,7 +260,7 @@ impl MycLocalSignerClient { .await .map_err(|_| transport_error(MycLocalSignerTransportErrorKind::Deadline))??; Ok(MycLocalSignerUntrustedResponse { - outer_correlation_id: response.correlation_id().clone(), + outer_correlation_id: response.correlation_id().as_str().into(), response: response.into_result(), }) } @@ -324,7 +339,7 @@ fn map_admin_error(error: radroots_service_host::AdminClientError) -> MycLocalSi #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -enum WireRole { +pub(crate) enum WireRole { Transport, User, Discovery, @@ -342,7 +357,7 @@ impl From<MycProviderRole> for WireRole { #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -enum WireProviderInstance { +pub(crate) enum WireProviderInstance { Transport, User, Discovery, @@ -360,7 +375,7 @@ impl From<MycProviderInstanceId> for WireProviderInstance { #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -enum WireCapability { +pub(crate) enum WireCapability { Describe, PublicIdentity, SignEvent, @@ -501,21 +516,21 @@ impl WireProviderInput { #[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, +pub(crate) struct LocalSignerResponse { + pub(crate) contract_version: u32, + pub(crate) provider_instance: WireProviderInstance, + pub(crate) role: WireRole, + pub(crate) operation_id: String, + pub(crate) correlation_id: String, + pub(crate) absolute_deadline_unix_ms: u64, + pub(crate) expected_identity: String, + pub(crate) capability: WireCapability, + pub(crate) result: WireProviderResult, } #[derive(Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] -enum WireProviderResult { +pub(crate) enum WireProviderResult { Describe { public_identity: String, protocol_version: u32, @@ -529,26 +544,30 @@ enum WireProviderResult { 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, }, } -struct ProtectedWireHex(Zeroizing<String>); +pub(crate) struct ProtectedWireHex(Zeroizing<String>); impl ProtectedWireHex { #[cfg(any(test, target_os = "linux", target_os = "macos"))] - fn from_bytes(bytes: &[u8]) -> Self { + pub(crate) fn from_bytes(bytes: &[u8]) -> Self { Self(Zeroizing::new(hex::encode(bytes))) } @@ -563,6 +582,12 @@ impl ProtectedWireHex { } Ok(Self(Zeroizing::new(value))) } + + pub(crate) fn into_bytes(self) -> Result<Zeroizing<Vec<u8>>, MycLocalSignerTransportError> { + hex::decode(self.0.as_bytes()) + .map(Zeroizing::new) + .map_err(|_| transport_error(MycLocalSignerTransportErrorKind::Response)) + } } impl Serialize for ProtectedWireHex { @@ -852,7 +877,7 @@ mod tests { let client = MycLocalSignerClient::new(&binding).expect("client"); let response = client.execute(&operation).await.expect("transport success"); assert_eq!( - response.outer_correlation_id.as_str(), + response.outer_correlation_id.as_ref(), hex::encode(operation.correlation_id().as_bytes()) ); assert_eq!(response.response.contract_version, 1); diff --git a/src/provider_verification.rs b/src/provider_verification.rs @@ -0,0 +1,1428 @@ +//! Independent semantic verification for untrusted signer-provider responses. + +use core::fmt; +use std::error::Error; + +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use nostr::{Event, UnsignedEvent}; +use zeroize::Zeroizing; + +use crate::provider_contract::MYC_PROVIDER_NIP44_PLAINTEXT_MAX_BYTES; +use crate::provider_local_signer::{ + LocalSignerUntrustedParts, MYC_LOCAL_SIGNER_TRANSPORT_CONTRACT_VERSION, + MycLocalSignerUntrustedResponse, ProtectedWireHex, WireCapability, WireProviderInstance, + WireProviderResult, WireRole, +}; +use crate::{ + MYC_PROVIDER_OUTPUT_MAX_BYTES, MycProviderBinding, MycProviderCapability, + MycProviderCapabilitySet, MycProviderCorrelationId, MycProviderInstanceId, MycProviderKind, + MycProviderNip44Version, MycProviderOperation, MycProviderOperationId, + MycProviderPublicIdentity, MycProviderRole, +}; + +const NIP44_V2_VERSION: u8 = 2; +const NIP44_V2_FIXED_PAYLOAD_BYTES: usize = 67; + +/// Positive injected completion-observation time for provider verification. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct MycProviderResponseObservedAtUnixMs(u64); + +impl MycProviderResponseObservedAtUnixMs { + /// Validates a positive UTC millisecond value representable by governed time types. + pub fn new(value: u64) -> Result<Self, MycProviderVerificationError> { + if value == 0 || i64::try_from(value).is_err() { + return Err(verification_error( + MycProviderVerificationErrorKind::InvalidObservationTime, + )); + } + Ok(Self(value)) + } + + /// Returns the exact injected value. + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } +} + +/// Stable source-free provider-verification failure classification. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycProviderVerificationErrorKind { + InvalidObservationTime, + InvalidBinding, + LateResponse, + ResponseBinding, + ResultShape, + Identity, + Capability, + Size, + Event, + Nip04, + Nip44, +} + +impl MycProviderVerificationErrorKind { + /// Returns the stable machine-facing safe code. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::InvalidObservationTime => "provider_observation_time_invalid", + Self::InvalidBinding => "provider_verification_binding_invalid", + Self::LateResponse => "provider_response_late", + Self::ResponseBinding => "provider_response_binding_invalid", + Self::ResultShape => "provider_result_shape_invalid", + Self::Identity => "provider_result_identity_invalid", + Self::Capability => "provider_result_capability_invalid", + Self::Size => "provider_result_size_invalid", + Self::Event => "provider_signed_event_invalid", + Self::Nip04 => "provider_nip04_result_invalid", + Self::Nip44 => "provider_nip44_result_invalid", + } + } + + const fn message(self) -> &'static str { + match self { + Self::InvalidObservationTime => "provider observation time is invalid", + Self::InvalidBinding => "provider verification binding is invalid", + Self::LateResponse => "provider response missed its deadline", + Self::ResponseBinding => "provider response binding is invalid", + Self::ResultShape => "provider result shape is invalid", + Self::Identity => "provider result identity is invalid", + Self::Capability => "provider result capability is invalid", + Self::Size => "provider result size is invalid", + Self::Event => "provider signed event is invalid", + Self::Nip04 => "provider NIP-04 result is invalid", + Self::Nip44 => "provider NIP-44 result is invalid", + } + } +} + +/// One source-free provider-verification failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct MycProviderVerificationError { + kind: MycProviderVerificationErrorKind, +} + +impl MycProviderVerificationError { + /// Returns the stable failure kind. + #[must_use] + pub const fn kind(self) -> MycProviderVerificationErrorKind { + 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 MycProviderVerificationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycProviderVerificationError") + .field("kind", &self.kind) + .finish() + } +} + +impl fmt::Display for MycProviderVerificationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.kind.message()) + } +} + +impl Error for MycProviderVerificationError {} + +const fn verification_error( + kind: MycProviderVerificationErrorKind, +) -> MycProviderVerificationError { + MycProviderVerificationError { kind } +} + +enum VerifiedProviderResult { + Describe { + public_identity: MycProviderPublicIdentity, + protocol_version: u32, + capabilities: MycProviderCapabilitySet, + maximum_request_bytes: u64, + }, + PublicIdentity(MycProviderPublicIdentity), + SignedEvent(Zeroizing<Vec<u8>>), + Protected { + payload: Zeroizing<Vec<u8>>, + nip44_version: Option<MycProviderNip44Version>, + }, +} + +/// One fully correlated, deadline-admitted, independently verified provider result. +/// +/// This value proves only local semantic admission. It is not publication, +/// durable commit evidence, or proof that a cancelled provider call had no effect. +pub struct MycVerifiedProviderResponse { + operation_id: MycProviderOperationId, + correlation_id: MycProviderCorrelationId, + instance: MycProviderInstanceId, + role: MycProviderRole, + capability: MycProviderCapability, + result: VerifiedProviderResult, +} + +impl MycVerifiedProviderResponse { + /// Returns the exact verified operation identity. + #[must_use] + pub const fn operation_id(&self) -> MycProviderOperationId { + self.operation_id + } + + /// Returns the exact verified correlation identity. + #[must_use] + pub const fn correlation_id(&self) -> MycProviderCorrelationId { + self.correlation_id + } + + /// Returns the exact verified provider instance. + #[must_use] + pub const fn instance(&self) -> MycProviderInstanceId { + self.instance + } + + /// Returns the exact verified provider role. + #[must_use] + pub const fn role(&self) -> MycProviderRole { + self.role + } + + /// Returns the exact verified capability. + #[must_use] + pub const fn capability(&self) -> MycProviderCapability { + self.capability + } + + /// Returns a verified public identity for describe/public-identity results. + #[must_use] + pub const fn public_identity(&self) -> Option<&MycProviderPublicIdentity> { + match &self.result { + VerifiedProviderResult::Describe { + public_identity, .. + } + | VerifiedProviderResult::PublicIdentity(public_identity) => Some(public_identity), + VerifiedProviderResult::SignedEvent(_) | VerifiedProviderResult::Protected { .. } => { + None + } + } + } + + /// Returns the verified protocol version for a describe result. + #[must_use] + pub const fn protocol_version(&self) -> Option<u32> { + match &self.result { + VerifiedProviderResult::Describe { + protocol_version, .. + } => Some(*protocol_version), + _ => None, + } + } + + /// Returns the verified capability set for a describe result. + #[must_use] + pub const fn capabilities(&self) -> Option<MycProviderCapabilitySet> { + match &self.result { + VerifiedProviderResult::Describe { capabilities, .. } => Some(*capabilities), + _ => None, + } + } + + /// Returns the verified provider request bound for a describe result. + #[must_use] + pub const fn maximum_request_bytes(&self) -> Option<u64> { + match &self.result { + VerifiedProviderResult::Describe { + maximum_request_bytes, + .. + } => Some(*maximum_request_bytes), + _ => None, + } + } + + /// Returns the exact canonical independently verified signed-event bytes. + /// + /// These are the bytes returned by the provider after canonical-form, + /// field, author, event-ID, and signature verification. Consumers that + /// persist or publish the result must retain these exact bytes. + #[must_use] + pub fn signed_event_bytes(&self) -> Option<&[u8]> { + match &self.result { + VerifiedProviderResult::SignedEvent(bytes) => Some(bytes), + _ => None, + } + } + + /// Returns the verified protected operation output, if applicable. + #[must_use] + pub fn protected_payload(&self) -> Option<&[u8]> { + match &self.result { + VerifiedProviderResult::Protected { payload, .. } => Some(payload), + _ => None, + } + } + + /// Returns the exact verified NIP-44 version, if applicable. + #[must_use] + pub const fn nip44_version(&self) -> Option<MycProviderNip44Version> { + match &self.result { + VerifiedProviderResult::Protected { nip44_version, .. } => *nip44_version, + _ => None, + } + } +} + +impl fmt::Debug for MycVerifiedProviderResponse { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycVerifiedProviderResponse") + .field("operation_id", &"[redacted]") + .field("correlation_id", &"[redacted]") + .field("instance", &self.instance) + .field("role", &self.role) + .field("capability", &self.capability) + .field("result", &"[redacted]") + .finish() + } +} + +impl MycLocalSignerUntrustedResponse { + /// Independently verifies this untrusted response against its original call. + pub fn verify( + self, + binding: &MycProviderBinding, + operation: &MycProviderOperation, + observed_at: MycProviderResponseObservedAtUnixMs, + ) -> Result<MycVerifiedProviderResponse, MycProviderVerificationError> { + verify_response(binding, operation, observed_at, self.into_parts()) + } +} + +fn verify_response( + binding: &MycProviderBinding, + operation: &MycProviderOperation, + observed_at: MycProviderResponseObservedAtUnixMs, + parts: LocalSignerUntrustedParts, +) -> Result<MycVerifiedProviderResponse, MycProviderVerificationError> { + if binding.kind() != MycProviderKind::LocalSigner + || operation.provider() != MycProviderKind::LocalSigner + || operation.role() != binding.role() + || operation.instance() != binding.instance() + || operation.expected_identity() != binding.expected_identity() + || !binding + .required_capabilities() + .contains(operation.input().capability()) + { + return Err(verification_error( + MycProviderVerificationErrorKind::InvalidBinding, + )); + } + if observed_at.get() > operation.deadline().get() { + return Err(verification_error( + MycProviderVerificationErrorKind::LateResponse, + )); + } + + let expected_operation_id = hex::encode(operation.operation_id().as_bytes()); + let expected_correlation_id = hex::encode(operation.correlation_id().as_bytes()); + let response = parts.response; + if response.contract_version != MYC_LOCAL_SIGNER_TRANSPORT_CONTRACT_VERSION + || response.provider_instance != WireProviderInstance::from(operation.instance()) + || response.role != WireRole::from(operation.role()) + || response.operation_id != expected_operation_id + || response.correlation_id != expected_correlation_id + || parts.outer_correlation_id.as_ref() != expected_correlation_id + || response.absolute_deadline_unix_ms != operation.deadline().get() + || response.expected_identity != operation.expected_identity().as_hex() + || response.capability != WireCapability::from(operation.input().capability()) + { + return Err(verification_error( + MycProviderVerificationErrorKind::ResponseBinding, + )); + } + + let result = verify_result(binding, operation, response.result)?; + Ok(MycVerifiedProviderResponse { + operation_id: operation.operation_id(), + correlation_id: operation.correlation_id(), + instance: operation.instance(), + role: operation.role(), + capability: operation.input().capability(), + result, + }) +} + +fn verify_result( + binding: &MycProviderBinding, + operation: &MycProviderOperation, + result: WireProviderResult, +) -> Result<VerifiedProviderResult, MycProviderVerificationError> { + match (operation.input().capability(), result) { + ( + MycProviderCapability::Describe, + WireProviderResult::Describe { + public_identity, + protocol_version, + capabilities, + maximum_request_bytes, + }, + ) => verify_describe( + binding, + public_identity, + protocol_version, + capabilities, + maximum_request_bytes, + ), + ( + MycProviderCapability::PublicIdentity, + WireProviderResult::PublicIdentity { public_identity }, + ) => { + verify_identity(operation.expected_identity(), &public_identity)?; + Ok(VerifiedProviderResult::PublicIdentity( + operation.expected_identity().clone(), + )) + } + (MycProviderCapability::SignEvent, WireProviderResult::SignEvent { payload_hex }) => { + verify_signed_event(operation, decode_payload(payload_hex)?) + } + ( + MycProviderCapability::Nip04Encrypt, + WireProviderResult::Nip04Encrypt { peer, payload_hex }, + ) => { + verify_peer(operation, &peer)?; + let payload = decode_payload(payload_hex)?; + let expected_plaintext = operation.input().bytes().ok_or_else(result_shape)?; + verify_nip04_ciphertext(&payload, Some(expected_plaintext.len()))?; + Ok(VerifiedProviderResult::Protected { + payload, + nip44_version: None, + }) + } + ( + MycProviderCapability::Nip04Decrypt, + WireProviderResult::Nip04Decrypt { peer, payload_hex }, + ) => { + verify_peer(operation, &peer)?; + let input = operation.input().bytes().ok_or_else(result_shape)?; + let ciphertext_length = verify_nip04_ciphertext(input, None)?; + let payload = decode_payload_allow_empty(payload_hex)?; + if payload.len() >= ciphertext_length { + return Err(verification_error(MycProviderVerificationErrorKind::Nip04)); + } + Ok(VerifiedProviderResult::Protected { + payload, + nip44_version: None, + }) + } + ( + MycProviderCapability::Nip44Encrypt, + WireProviderResult::Nip44Encrypt { + peer, + version, + payload_hex, + }, + ) => { + verify_peer(operation, &peer)?; + let requested_version = operation.input().nip44_version().ok_or_else(result_shape)?; + if version != requested_version.as_u8() { + return Err(verification_error(MycProviderVerificationErrorKind::Nip44)); + } + let plaintext = operation.input().bytes().ok_or_else(result_shape)?; + let payload = decode_payload(payload_hex)?; + verify_nip44_ciphertext(&payload, Some(plaintext.len()), requested_version)?; + Ok(VerifiedProviderResult::Protected { + payload, + nip44_version: Some(requested_version), + }) + } + ( + MycProviderCapability::Nip44Decrypt, + WireProviderResult::Nip44Decrypt { + peer, + version, + payload_hex, + }, + ) => { + verify_peer(operation, &peer)?; + let requested_version = operation.input().nip44_version().ok_or_else(result_shape)?; + if version != requested_version.as_u8() { + return Err(verification_error(MycProviderVerificationErrorKind::Nip44)); + } + let input = operation.input().bytes().ok_or_else(result_shape)?; + let padded_length = verify_nip44_ciphertext(input, None, requested_version)?; + let payload = decode_payload(payload_hex)?; + if payload.len() > padded_length + || payload.len() > MYC_PROVIDER_NIP44_PLAINTEXT_MAX_BYTES + { + return Err(verification_error(MycProviderVerificationErrorKind::Nip44)); + } + Ok(VerifiedProviderResult::Protected { + payload, + nip44_version: Some(requested_version), + }) + } + _ => Err(result_shape()), + } +} + +fn verify_describe( + binding: &MycProviderBinding, + public_identity: String, + protocol_version: u32, + capabilities: Vec<WireCapability>, + maximum_request_bytes: u64, +) -> Result<VerifiedProviderResult, MycProviderVerificationError> { + verify_identity(binding.expected_identity(), &public_identity)?; + if protocol_version != MYC_LOCAL_SIGNER_TRANSPORT_CONTRACT_VERSION { + return Err(verification_error( + MycProviderVerificationErrorKind::ResponseBinding, + )); + } + if capabilities.is_empty() || capabilities.len() > 7 { + return Err(verification_error( + MycProviderVerificationErrorKind::Capability, + )); + } + let capabilities: Vec<MycProviderCapability> = capabilities + .into_iter() + .map(MycProviderCapability::from) + .collect(); + let capabilities = MycProviderCapabilitySet::new(&capabilities) + .map_err(|_| verification_error(MycProviderVerificationErrorKind::Capability))?; + if binding + .required_capabilities() + .iter() + .any(|capability| !capabilities.contains(capability)) + { + return Err(verification_error( + MycProviderVerificationErrorKind::Capability, + )); + } + let limits = binding + .local_signer_limits() + .ok_or_else(|| verification_error(MycProviderVerificationErrorKind::InvalidBinding))?; + if maximum_request_bytes != limits.request_max_bytes() { + return Err(verification_error(MycProviderVerificationErrorKind::Size)); + } + Ok(VerifiedProviderResult::Describe { + public_identity: binding.expected_identity().clone(), + protocol_version, + capabilities, + maximum_request_bytes, + }) +} + +fn verify_identity( + expected: &MycProviderPublicIdentity, + actual: &str, +) -> Result<(), MycProviderVerificationError> { + if actual != expected.as_hex() { + return Err(verification_error( + MycProviderVerificationErrorKind::Identity, + )); + } + Ok(()) +} + +fn verify_peer( + operation: &MycProviderOperation, + actual: &str, +) -> Result<(), MycProviderVerificationError> { + if operation + .input() + .peer() + .map(MycProviderPublicIdentity::as_hex) + != Some(actual) + { + return Err(verification_error( + MycProviderVerificationErrorKind::ResponseBinding, + )); + } + Ok(()) +} + +fn decode_payload( + payload: ProtectedWireHex, +) -> Result<Zeroizing<Vec<u8>>, MycProviderVerificationError> { + let payload = payload + .into_bytes() + .map_err(|_| verification_error(MycProviderVerificationErrorKind::ResultShape))?; + if payload.is_empty() || payload.len() > MYC_PROVIDER_OUTPUT_MAX_BYTES { + return Err(verification_error(MycProviderVerificationErrorKind::Size)); + } + Ok(payload) +} + +fn decode_payload_allow_empty( + payload: ProtectedWireHex, +) -> Result<Zeroizing<Vec<u8>>, MycProviderVerificationError> { + let payload = payload + .into_bytes() + .map_err(|_| verification_error(MycProviderVerificationErrorKind::ResultShape))?; + if payload.len() > MYC_PROVIDER_OUTPUT_MAX_BYTES { + return Err(verification_error(MycProviderVerificationErrorKind::Size)); + } + Ok(payload) +} + +fn verify_signed_event( + operation: &MycProviderOperation, + payload: Zeroizing<Vec<u8>>, +) -> Result<VerifiedProviderResult, MycProviderVerificationError> { + let unsigned_bytes = operation.input().bytes().ok_or_else(result_shape)?; + let mut unsigned: UnsignedEvent = serde_json::from_slice(unsigned_bytes) + .map_err(|_| verification_error(MycProviderVerificationErrorKind::Event))?; + let canonical_unsigned = serde_json::to_vec(&unsigned) + .map_err(|_| verification_error(MycProviderVerificationErrorKind::Event))?; + if canonical_unsigned != unsigned_bytes + || unsigned.verify_id().is_err() + || unsigned.pubkey.to_hex() != operation.expected_identity().as_hex() + { + return Err(verification_error(MycProviderVerificationErrorKind::Event)); + } + let expected_id = unsigned.id(); + let event: Event = serde_json::from_slice(&payload) + .map_err(|_| verification_error(MycProviderVerificationErrorKind::Event))?; + let canonical_event = serde_json::to_vec(&event) + .map_err(|_| verification_error(MycProviderVerificationErrorKind::Event))?; + if canonical_event.as_slice() != payload.as_slice() + || event.pubkey != unsigned.pubkey + || event.created_at != unsigned.created_at + || event.kind != unsigned.kind + || event.tags != unsigned.tags + || event.content != unsigned.content + || event.id != expected_id + || event.verify().is_err() + { + return Err(verification_error(MycProviderVerificationErrorKind::Event)); + } + Ok(VerifiedProviderResult::SignedEvent(payload)) +} + +fn verify_nip04_ciphertext( + payload: &[u8], + plaintext_length: Option<usize>, +) -> Result<usize, MycProviderVerificationError> { + let rendered = core::str::from_utf8(payload) + .map_err(|_| verification_error(MycProviderVerificationErrorKind::Nip04))?; + let (ciphertext, iv) = rendered + .split_once("?iv=") + .ok_or_else(|| verification_error(MycProviderVerificationErrorKind::Nip04))?; + if ciphertext.is_empty() || iv.is_empty() || iv.contains("?iv=") { + return Err(verification_error(MycProviderVerificationErrorKind::Nip04)); + } + let ciphertext = decode_canonical_base64(ciphertext, MycProviderVerificationErrorKind::Nip04)?; + let iv = decode_canonical_base64(iv, MycProviderVerificationErrorKind::Nip04)?; + if iv.len() != 16 || ciphertext.is_empty() || !ciphertext.len().is_multiple_of(16) { + return Err(verification_error(MycProviderVerificationErrorKind::Nip04)); + } + if let Some(length) = plaintext_length { + let expected = length + .checked_div(16) + .and_then(|blocks| blocks.checked_add(1)) + .and_then(|blocks| blocks.checked_mul(16)) + .ok_or_else(|| verification_error(MycProviderVerificationErrorKind::Size))?; + if ciphertext.len() != expected { + return Err(verification_error(MycProviderVerificationErrorKind::Nip04)); + } + } + Ok(ciphertext.len()) +} + +fn verify_nip44_ciphertext( + payload: &[u8], + plaintext_length: Option<usize>, + version: MycProviderNip44Version, +) -> Result<usize, MycProviderVerificationError> { + let rendered = core::str::from_utf8(payload) + .map_err(|_| verification_error(MycProviderVerificationErrorKind::Nip44))?; + let decoded = decode_canonical_base64(rendered, MycProviderVerificationErrorKind::Nip44)?; + if version.as_u8() != NIP44_V2_VERSION + || decoded.first().copied() != Some(version.as_u8()) + || decoded.len() < NIP44_V2_FIXED_PAYLOAD_BYTES + 32 + { + return Err(verification_error(MycProviderVerificationErrorKind::Nip44)); + } + let padded = decoded + .len() + .checked_sub(NIP44_V2_FIXED_PAYLOAD_BYTES) + .ok_or_else(|| verification_error(MycProviderVerificationErrorKind::Nip44))?; + if let Some(length) = plaintext_length { + let expected = nip44_padding_length(length) + .and_then(|value| NIP44_V2_FIXED_PAYLOAD_BYTES.checked_add(value)) + .ok_or_else(|| verification_error(MycProviderVerificationErrorKind::Nip44))?; + if decoded.len() != expected { + return Err(verification_error(MycProviderVerificationErrorKind::Nip44)); + } + } else if !is_valid_nip44_padding_length(padded) { + return Err(verification_error(MycProviderVerificationErrorKind::Nip44)); + } + Ok(padded) +} + +fn decode_canonical_base64( + value: &str, + kind: MycProviderVerificationErrorKind, +) -> Result<Vec<u8>, MycProviderVerificationError> { + let decoded = BASE64_STANDARD + .decode(value) + .map_err(|_| verification_error(kind))?; + if BASE64_STANDARD.encode(&decoded) != value { + return Err(verification_error(kind)); + } + Ok(decoded) +} + +fn nip44_padding_length(length: usize) -> Option<usize> { + if length == 0 || length > MYC_PROVIDER_NIP44_PLAINTEXT_MAX_BYTES { + return None; + } + if length <= 32 { + return Some(32); + } + let next_power = length.checked_next_power_of_two()?; + let chunk = if next_power <= 256 { + 32 + } else { + next_power.checked_div(8)? + }; + chunk.checked_mul(length.checked_sub(1)?.checked_div(chunk)?.checked_add(1)?) +} + +fn is_valid_nip44_padding_length(padded: usize) -> bool { + if !(32..=65_536).contains(&padded) { + return false; + } + if padded <= 256 { + return padded.is_multiple_of(32); + } + let Some(next_power) = padded.checked_next_power_of_two() else { + return false; + }; + let chunk = next_power / 8; + padded.is_multiple_of(chunk) +} + +const fn result_shape() -> MycProviderVerificationError { + verification_error(MycProviderVerificationErrorKind::ResultShape) +} + +impl From<WireCapability> for MycProviderCapability { + fn from(capability: WireCapability) -> Self { + match capability { + WireCapability::Describe => Self::Describe, + WireCapability::PublicIdentity => Self::PublicIdentity, + WireCapability::SignEvent => Self::SignEvent, + WireCapability::Nip04Encrypt => Self::Nip04Encrypt, + WireCapability::Nip04Decrypt => Self::Nip04Decrypt, + WireCapability::Nip44Encrypt => Self::Nip44Encrypt, + WireCapability::Nip44Decrypt => Self::Nip44Decrypt, + } + } +} + +#[cfg(test)] +mod tests { + use std::error::Error as _; + use std::path::Path; + + use nostr::nips::{nip04, nip44}; + use nostr::{Keys, Kind, SecretKey, Tag, Timestamp}; + + use crate::provider_local_signer::{LocalSignerResponse, MycLocalSignerUntrustedResponse}; + use crate::{ + MycConfigProfile, MycProviderCorrelationId, MycProviderDeadlineUnixMs, + MycProviderOperationId, MycProviderOperationInput, parse_myc_config_v1, + }; + + use super::*; + + const CONFIG: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); + + fn keys(seed: u8) -> Keys { + Keys::new(SecretKey::from_slice(&[seed; 32]).expect("test secret")) + } + + fn binding(identity: &Keys) -> MycProviderBinding { + let source = CONFIG + .replace( + "/run/radroots/services/myc/primary/user-signer.sock", + Path::new("/run/test-verifier.sock") + .to_str() + .expect("test socket"), + ) + .replace( + "2222222222222222222222222222222222222222222222222222222222222222", + &identity.public_key().to_hex(), + ); + parse_myc_config_v1(source.as_bytes(), MycConfigProfile::RepoLocal) + .expect("test config") + .provider_contract() + .binding(MycProviderRole::User) + .expect("user binding") + .clone() + } + + fn encrypted_transport_binding() -> MycProviderBinding { + parse_myc_config_v1(CONFIG.as_bytes(), MycConfigProfile::RepoLocal) + .expect("test config") + .provider_contract() + .binding(MycProviderRole::Transport) + .expect("transport binding") + .clone() + } + + fn make_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") + } + + fn parts( + operation: &MycProviderOperation, + result: WireProviderResult, + ) -> LocalSignerUntrustedParts { + let operation_id = hex::encode(operation.operation_id().as_bytes()); + let correlation_id = hex::encode(operation.correlation_id().as_bytes()); + LocalSignerUntrustedParts { + outer_correlation_id: correlation_id.clone().into(), + response: LocalSignerResponse { + contract_version: MYC_LOCAL_SIGNER_TRANSPORT_CONTRACT_VERSION, + provider_instance: operation.instance().into(), + role: operation.role().into(), + operation_id, + correlation_id, + absolute_deadline_unix_ms: operation.deadline().get(), + expected_identity: operation.expected_identity().as_hex().to_owned(), + capability: operation.input().capability().into(), + result, + }, + } + } + + fn observed() -> MycProviderResponseObservedAtUnixMs { + MycProviderResponseObservedAtUnixMs::new(1_999_999_999_999).expect("observed") + } + + fn public_identity_result(operation: &MycProviderOperation) -> WireProviderResult { + WireProviderResult::PublicIdentity { + public_identity: operation.expected_identity().as_hex().to_owned(), + } + } + + fn verify( + binding: &MycProviderBinding, + operation: &MycProviderOperation, + result: WireProviderResult, + ) -> Result<MycVerifiedProviderResponse, MycProviderVerificationError> { + MycLocalSignerUntrustedResponse::from_parts(parts(operation, result)).verify( + binding, + operation, + observed(), + ) + } + + fn assert_binding_rejected( + binding: &MycProviderBinding, + operation: &MycProviderOperation, + mutate: impl FnOnce(&mut LocalSignerUntrustedParts), + ) { + let mut untrusted = parts(operation, public_identity_result(operation)); + mutate(&mut untrusted); + let error = MycLocalSignerUntrustedResponse::from_parts(untrusted) + .verify(binding, operation, observed()) + .expect_err("binding mismatch"); + assert_eq!( + error.kind(), + MycProviderVerificationErrorKind::ResponseBinding + ); + } + + #[test] + fn observed_time_and_errors_are_bounded_source_free_and_redacted() { + assert!(MycProviderResponseObservedAtUnixMs::new(0).is_err()); + assert!(MycProviderResponseObservedAtUnixMs::new(i64::MAX as u64).is_ok()); + assert!(MycProviderResponseObservedAtUnixMs::new(i64::MAX as u64 + 1).is_err()); + for kind in [ + MycProviderVerificationErrorKind::InvalidObservationTime, + MycProviderVerificationErrorKind::InvalidBinding, + MycProviderVerificationErrorKind::LateResponse, + MycProviderVerificationErrorKind::ResponseBinding, + MycProviderVerificationErrorKind::ResultShape, + MycProviderVerificationErrorKind::Identity, + MycProviderVerificationErrorKind::Capability, + MycProviderVerificationErrorKind::Size, + MycProviderVerificationErrorKind::Event, + MycProviderVerificationErrorKind::Nip04, + MycProviderVerificationErrorKind::Nip44, + ] { + let error = verification_error(kind); + assert_eq!(error.kind(), kind); + assert!(!error.code().is_empty()); + assert!(!error.to_string().is_empty()); + assert!(error.source().is_none()); + } + } + + #[test] + fn every_outer_and_inner_binding_must_match_the_original_operation() { + let identity = keys(1); + let binding = binding(&identity); + let operation = make_operation(&binding, 3, MycProviderOperationInput::public_identity()); + let verified = verify(&binding, &operation, public_identity_result(&operation)) + .expect("verified identity"); + assert_eq!(verified.operation_id(), operation.operation_id()); + assert_eq!(verified.correlation_id(), operation.correlation_id()); + assert_eq!(verified.role(), MycProviderRole::User); + assert_eq!(verified.instance(), MycProviderInstanceId::User); + assert_eq!( + verified.public_identity(), + Some(operation.expected_identity()) + ); + + let error = MycLocalSignerUntrustedResponse::from_parts(parts( + &operation, + public_identity_result(&operation), + )) + .verify(&encrypted_transport_binding(), &operation, observed()) + .expect_err("wrong configured binding"); + assert_eq!( + error.kind(), + MycProviderVerificationErrorKind::InvalidBinding + ); + + assert_binding_rejected(&binding, &operation, |parts| { + parts.outer_correlation_id = "00".into(); + }); + assert_binding_rejected(&binding, &operation, |parts| { + parts.response.contract_version = 2; + }); + assert_binding_rejected(&binding, &operation, |parts| { + parts.response.operation_id = "00".into(); + }); + assert_binding_rejected(&binding, &operation, |parts| { + parts.response.correlation_id = "00".into(); + }); + assert_binding_rejected(&binding, &operation, |parts| { + parts.response.absolute_deadline_unix_ms -= 1; + }); + assert_binding_rejected(&binding, &operation, |parts| { + parts.response.expected_identity = keys(2).public_key().to_hex(); + }); + assert_binding_rejected(&binding, &operation, |parts| { + parts.response.capability = WireCapability::Describe; + }); + assert_binding_rejected(&binding, &operation, |parts| { + parts.response.role = WireRole::Transport; + }); + assert_binding_rejected(&binding, &operation, |parts| { + parts.response.provider_instance = WireProviderInstance::Transport; + }); + + let late = MycProviderResponseObservedAtUnixMs::new(operation.deadline().get() + 1) + .expect("late observation"); + let error = MycLocalSignerUntrustedResponse::from_parts(parts( + &operation, + public_identity_result(&operation), + )) + .verify(&binding, &operation, late) + .expect_err("late result"); + assert_eq!(error.kind(), MycProviderVerificationErrorKind::LateResponse); + + let exact = MycProviderResponseObservedAtUnixMs::new(operation.deadline().get()) + .expect("exact deadline"); + MycLocalSignerUntrustedResponse::from_parts(parts( + &operation, + public_identity_result(&operation), + )) + .verify(&binding, &operation, exact) + .expect("exact deadline admitted"); + } + + #[test] + fn describe_requires_exact_identity_protocol_capabilities_and_size() { + let identity = keys(1); + let binding = binding(&identity); + let operation = make_operation(&binding, 4, MycProviderOperationInput::describe()); + let all = [ + WireCapability::Describe, + WireCapability::PublicIdentity, + WireCapability::SignEvent, + WireCapability::Nip04Encrypt, + WireCapability::Nip04Decrypt, + WireCapability::Nip44Encrypt, + WireCapability::Nip44Decrypt, + ]; + let result = || WireProviderResult::Describe { + public_identity: operation.expected_identity().as_hex().to_owned(), + protocol_version: 1, + capabilities: all.to_vec(), + maximum_request_bytes: binding + .local_signer_limits() + .expect("limits") + .request_max_bytes(), + }; + let verified = verify(&binding, &operation, result()).expect("describe"); + assert_eq!(verified.protocol_version(), Some(1)); + assert_eq!(verified.capabilities().expect("capabilities").len(), 7); + assert_eq!(verified.maximum_request_bytes(), Some(65_536)); + + let duplicate = WireProviderResult::Describe { + public_identity: operation.expected_identity().as_hex().to_owned(), + protocol_version: 1, + capabilities: vec![WireCapability::Describe, WireCapability::Describe], + maximum_request_bytes: 65_536, + }; + assert_eq!( + verify(&binding, &operation, duplicate).unwrap_err().kind(), + MycProviderVerificationErrorKind::Capability + ); + let missing = WireProviderResult::Describe { + public_identity: operation.expected_identity().as_hex().to_owned(), + protocol_version: 1, + capabilities: vec![WireCapability::Describe, WireCapability::PublicIdentity], + maximum_request_bytes: 65_536, + }; + assert_eq!( + verify(&binding, &operation, missing).unwrap_err().kind(), + MycProviderVerificationErrorKind::Capability + ); + let wrong_size = WireProviderResult::Describe { + public_identity: operation.expected_identity().as_hex().to_owned(), + protocol_version: 1, + capabilities: all.to_vec(), + maximum_request_bytes: 65_535, + }; + assert_eq!( + verify(&binding, &operation, wrong_size).unwrap_err().kind(), + MycProviderVerificationErrorKind::Size + ); + let wrong_protocol = WireProviderResult::Describe { + public_identity: operation.expected_identity().as_hex().to_owned(), + protocol_version: 2, + capabilities: all.to_vec(), + maximum_request_bytes: 65_536, + }; + assert_eq!( + verify(&binding, &operation, wrong_protocol) + .unwrap_err() + .kind(), + MycProviderVerificationErrorKind::ResponseBinding + ); + let wrong_identity = WireProviderResult::Describe { + public_identity: keys(2).public_key().to_hex(), + protocol_version: 1, + capabilities: all.to_vec(), + maximum_request_bytes: 65_536, + }; + assert_eq!( + verify(&binding, &operation, wrong_identity) + .unwrap_err() + .kind(), + MycProviderVerificationErrorKind::Identity + ); + } + + #[test] + fn signed_event_must_match_every_canonical_unsigned_field_and_signature() { + let identity = keys(1); + let binding = binding(&identity); + let unsigned = UnsignedEvent::new( + identity.public_key(), + Timestamp::from_secs(1_700_000_000), + Kind::TextNote, + Vec::<Tag>::new(), + "signed content", + ); + let unsigned_bytes = serde_json::to_vec(&unsigned).expect("unsigned JSON"); + let operation = make_operation( + &binding, + 5, + MycProviderOperationInput::sign_event(&unsigned_bytes).expect("sign input"), + ); + let event = unsigned + .clone() + .sign_with_keys(&identity) + .expect("signed event"); + let event_bytes = serde_json::to_vec(&event).expect("event JSON"); + let verified = verify( + &binding, + &operation, + WireProviderResult::SignEvent { + payload_hex: ProtectedWireHex::from_bytes(&event_bytes), + }, + ) + .expect("verified event"); + assert_eq!(verified.signed_event_bytes(), Some(event_bytes.as_slice())); + assert!(!format!("{verified:?}").contains("signed content")); + + let altered = UnsignedEvent::new( + identity.public_key(), + Timestamp::from_secs(1_700_000_000), + Kind::TextNote, + Vec::<Tag>::new(), + "altered content", + ) + .sign_with_keys(&identity) + .expect("altered event"); + let error = verify( + &binding, + &operation, + WireProviderResult::SignEvent { + payload_hex: ProtectedWireHex::from_bytes( + &serde_json::to_vec(&altered).expect("altered JSON"), + ), + }, + ) + .expect_err("altered fields"); + assert_eq!(error.kind(), MycProviderVerificationErrorKind::Event); + + let other_identity = keys(2); + let wrong_author = UnsignedEvent::new( + other_identity.public_key(), + Timestamp::from_secs(1_700_000_000), + Kind::TextNote, + Vec::<Tag>::new(), + "signed content", + ) + .sign_with_keys(&other_identity) + .expect("wrong-author event"); + let error = verify( + &binding, + &operation, + WireProviderResult::SignEvent { + payload_hex: ProtectedWireHex::from_bytes( + &serde_json::to_vec(&wrong_author).expect("wrong-author JSON"), + ), + }, + ) + .expect_err("wrong author"); + assert_eq!(error.kind(), MycProviderVerificationErrorKind::Event); + + let mut wrong_id = event.clone(); + wrong_id.id = nostr::EventId::all_zeros(); + let error = verify( + &binding, + &operation, + WireProviderResult::SignEvent { + payload_hex: ProtectedWireHex::from_bytes( + &serde_json::to_vec(&wrong_id).expect("wrong-id JSON"), + ), + }, + ) + .expect_err("wrong event ID"); + assert_eq!(error.kind(), MycProviderVerificationErrorKind::Event); + + let mut wrong_signature = event.clone(); + wrong_signature.sig = altered.sig; + let error = verify( + &binding, + &operation, + WireProviderResult::SignEvent { + payload_hex: ProtectedWireHex::from_bytes( + &serde_json::to_vec(&wrong_signature).expect("wrong-signature JSON"), + ), + }, + ) + .expect_err("wrong signature"); + assert_eq!(error.kind(), MycProviderVerificationErrorKind::Event); + + let noncanonical_input = format!(" {}", String::from_utf8(unsigned_bytes).unwrap()); + let noncanonical_operation = make_operation( + &binding, + 6, + MycProviderOperationInput::sign_event(noncanonical_input.as_bytes()) + .expect("bounded input"), + ); + let error = verify( + &binding, + &noncanonical_operation, + WireProviderResult::SignEvent { + payload_hex: ProtectedWireHex::from_bytes(&event_bytes), + }, + ) + .expect_err("noncanonical unsigned event"); + assert_eq!(error.kind(), MycProviderVerificationErrorKind::Event); + } + + #[test] + fn nip04_direction_peer_shape_and_padding_are_independently_bound() { + let identity = keys(1); + let peer = keys(2); + let binding = binding(&identity); + let peer_identity = + MycProviderPublicIdentity::new(&peer.public_key().to_hex()).expect("peer"); + let plaintext = b"nip04 plaintext"; + let ciphertext = nip04::encrypt( + identity.secret_key(), + &peer.public_key(), + plaintext.as_slice(), + ) + .expect("nip04 ciphertext"); + let encrypt = make_operation( + &binding, + 7, + MycProviderOperationInput::nip04_encrypt(peer_identity.clone(), plaintext) + .expect("encrypt input"), + ); + let verified = verify( + &binding, + &encrypt, + WireProviderResult::Nip04Encrypt { + peer: peer.public_key().to_hex(), + payload_hex: ProtectedWireHex::from_bytes(ciphertext.as_bytes()), + }, + ) + .expect("nip04 encrypt"); + assert_eq!(verified.protected_payload(), Some(ciphertext.as_bytes())); + + let error = verify( + &binding, + &encrypt, + WireProviderResult::Nip04Encrypt { + peer: keys(3).public_key().to_hex(), + payload_hex: ProtectedWireHex::from_bytes(ciphertext.as_bytes()), + }, + ) + .expect_err("wrong peer"); + assert_eq!( + error.kind(), + MycProviderVerificationErrorKind::ResponseBinding + ); + let error = verify( + &binding, + &encrypt, + WireProviderResult::Nip04Encrypt { + peer: peer.public_key().to_hex(), + payload_hex: ProtectedWireHex::from_bytes(b"not-ciphertext"), + }, + ) + .expect_err("malformed ciphertext"); + assert_eq!(error.kind(), MycProviderVerificationErrorKind::Nip04); + + let decrypt = make_operation( + &binding, + 8, + MycProviderOperationInput::nip04_decrypt(peer_identity, ciphertext.as_bytes()) + .expect("decrypt input"), + ); + let verified = verify( + &binding, + &decrypt, + WireProviderResult::Nip04Decrypt { + peer: peer.public_key().to_hex(), + payload_hex: ProtectedWireHex::from_bytes(plaintext), + }, + ) + .expect("nip04 decrypt"); + assert_eq!(verified.protected_payload(), Some(plaintext.as_slice())); + + let error = verify( + &binding, + &decrypt, + WireProviderResult::Nip04Decrypt { + peer: peer.public_key().to_hex(), + payload_hex: ProtectedWireHex::from_bytes(&[0; 32]), + }, + ) + .expect_err("plaintext cannot fill the complete ciphertext block capacity"); + assert_eq!(error.kind(), MycProviderVerificationErrorKind::Nip04); + } + + #[test] + fn nip44_direction_peer_version_shape_and_padding_are_independently_bound() { + let identity = keys(1); + let peer = keys(2); + let binding = binding(&identity); + let peer_identity = + MycProviderPublicIdentity::new(&peer.public_key().to_hex()).expect("peer"); + let plaintext = b"nip44 plaintext"; + assert!( + MycProviderOperationInput::nip44_encrypt( + peer_identity.clone(), + MycProviderNip44Version::V2, + b"" + ) + .is_err() + ); + let ciphertext = nip44::encrypt( + identity.secret_key(), + &peer.public_key(), + plaintext.as_slice(), + nip44::Version::V2, + ) + .expect("nip44 ciphertext"); + let encrypt = make_operation( + &binding, + 9, + MycProviderOperationInput::nip44_encrypt( + peer_identity.clone(), + MycProviderNip44Version::V2, + plaintext, + ) + .expect("encrypt input"), + ); + let verified = verify( + &binding, + &encrypt, + WireProviderResult::Nip44Encrypt { + peer: peer.public_key().to_hex(), + version: 2, + payload_hex: ProtectedWireHex::from_bytes(ciphertext.as_bytes()), + }, + ) + .expect("nip44 encrypt"); + assert_eq!(verified.nip44_version(), Some(MycProviderNip44Version::V2)); + + let error = verify( + &binding, + &encrypt, + WireProviderResult::Nip44Encrypt { + peer: peer.public_key().to_hex(), + version: 3, + payload_hex: ProtectedWireHex::from_bytes(ciphertext.as_bytes()), + }, + ) + .expect_err("wrong version"); + assert_eq!(error.kind(), MycProviderVerificationErrorKind::Nip44); + + let error = verify( + &binding, + &encrypt, + WireProviderResult::Nip44Encrypt { + peer: keys(3).public_key().to_hex(), + version: 2, + payload_hex: ProtectedWireHex::from_bytes(ciphertext.as_bytes()), + }, + ) + .expect_err("wrong peer"); + assert_eq!( + error.kind(), + MycProviderVerificationErrorKind::ResponseBinding + ); + let error = verify( + &binding, + &encrypt, + WireProviderResult::Nip44Encrypt { + peer: peer.public_key().to_hex(), + version: 2, + payload_hex: ProtectedWireHex::from_bytes(b"not-base64"), + }, + ) + .expect_err("malformed ciphertext"); + assert_eq!(error.kind(), MycProviderVerificationErrorKind::Nip44); + + let decrypt = make_operation( + &binding, + 10, + MycProviderOperationInput::nip44_decrypt( + peer_identity, + MycProviderNip44Version::V2, + ciphertext.as_bytes(), + ) + .expect("decrypt input"), + ); + let verified = verify( + &binding, + &decrypt, + WireProviderResult::Nip44Decrypt { + peer: peer.public_key().to_hex(), + version: 2, + payload_hex: ProtectedWireHex::from_bytes(plaintext), + }, + ) + .expect("nip44 decrypt"); + assert_eq!(verified.protected_payload(), Some(plaintext.as_slice())); + + let error = verify( + &binding, + &decrypt, + WireProviderResult::Nip44Decrypt { + peer: peer.public_key().to_hex(), + version: 2, + payload_hex: ProtectedWireHex::from_bytes(&[0; 33]), + }, + ) + .expect_err("plaintext cannot exceed the ciphertext padding capacity"); + assert_eq!(error.kind(), MycProviderVerificationErrorKind::Nip44); + } + + #[test] + fn wrong_result_shape_and_semantic_output_overflow_fail_before_exposure() { + let identity = keys(1); + let binding = binding(&identity); + let operation = make_operation(&binding, 11, MycProviderOperationInput::public_identity()); + let error = verify( + &binding, + &operation, + WireProviderResult::SignEvent { + payload_hex: ProtectedWireHex::from_bytes(b"event"), + }, + ) + .expect_err("wrong shape"); + assert_eq!(error.kind(), MycProviderVerificationErrorKind::ResultShape); + + let peer = keys(2); + let peer_identity = + MycProviderPublicIdentity::new(&peer.public_key().to_hex()).expect("peer"); + let decrypt = make_operation( + &binding, + 12, + MycProviderOperationInput::nip04_decrypt( + peer_identity, + nip04::encrypt(identity.secret_key(), &peer.public_key(), "input") + .expect("ciphertext") + .as_bytes(), + ) + .expect("decrypt input"), + ); + let oversized = vec![7_u8; MYC_PROVIDER_OUTPUT_MAX_BYTES + 1]; + let error = verify( + &binding, + &decrypt, + WireProviderResult::Nip04Decrypt { + peer: peer.public_key().to_hex(), + payload_hex: ProtectedWireHex::from_bytes(&oversized), + }, + ) + .expect_err("oversized output"); + assert_eq!(error.kind(), MycProviderVerificationErrorKind::Size); + } + + #[test] + fn nip44_padding_model_matches_all_exact_protocol_boundaries() { + for (length, padded) in [ + (1, 32), + (32, 32), + (33, 64), + (256, 256), + (257, 320), + (MYC_PROVIDER_NIP44_PLAINTEXT_MAX_BYTES, 65_536), + ] { + assert_eq!(nip44_padding_length(length), Some(padded)); + assert!(is_valid_nip44_padding_length(padded)); + } + assert_eq!(nip44_padding_length(0), None); + assert_eq!( + nip44_padding_length(MYC_PROVIDER_NIP44_PLAINTEXT_MAX_BYTES + 1), + None + ); + for invalid in [0, 31, 33, 288, 65_537] { + assert!(!is_valid_nip44_padding_length(invalid)); + } + } +} diff --git a/tests/services_hardening_local_signer_transport.rs b/tests/services_hardening_local_signer_transport.rs @@ -52,19 +52,19 @@ fn machine_contract_freezes_the_complete_local_signer_transport() { }, "nip04_encrypt": { "input_fields": ["peer", "payload_hex"], - "result_fields": ["payload_hex"] + "result_fields": ["peer", "payload_hex"] }, "nip04_decrypt": { "input_fields": ["peer", "payload_hex"], - "result_fields": ["payload_hex"] + "result_fields": ["peer", "payload_hex"] }, "nip44_encrypt": { "input_fields": ["peer", "version", "payload_hex"], - "result_fields": ["version", "payload_hex"] + "result_fields": ["peer", "version", "payload_hex"] }, "nip44_decrypt": { "input_fields": ["peer", "version", "payload_hex"], - "result_fields": ["version", "payload_hex"] + "result_fields": ["peer", "version", "payload_hex"] } }, "protected_payload_encoding": "lowercase_hex", diff --git a/tests/services_hardening_provider_verification.rs b/tests/services_hardening_provider_verification.rs @@ -0,0 +1,149 @@ +#![forbid(unsafe_code)] + +use serde_json::json; + +const CONTRACT: &str = + include_str!("../contracts/services_hardening/provider_verification.v1.json"); +const LIB_SOURCE: &str = include_str!("../src/lib.rs"); +const VERIFICATION_SOURCE: &str = include_str!("../src/provider_verification.rs"); +const TRANSPORT_SOURCE: &str = include_str!("../src/provider_local_signer.rs"); + +#[test] +fn machine_contract_freezes_independent_provider_verification() { + let actual: serde_json::Value = serde_json::from_str(CONTRACT).expect("contract JSON"); + assert_eq!( + actual, + json!({ + "schema": "radroots.myc.provider-verification", + "schema_version": 1, + "provider_contract_version": 1, + "local_signer_transport_contract_version": 1, + "observation_time": { + "source": "injected", + "minimum_unix_ms": 1, + "maximum_unix_ms": 9223372036854775807_i64 + }, + "verification_order": [ + "configured_binding", "absolute_deadline", "outer_correlation", + "response_envelope_binding", "result_shape", "semantic_result" + ], + "response_binding": [ + "contract_version", "provider_instance", "role", "operation_id", + "correlation_id", "absolute_deadline_unix_ms", "expected_identity", + "capability" + ], + "describe": { + "identity": "exact_expected", + "protocol_version": 1, + "capabilities": "closed_unique_contains_role_requirements", + "maximum_request_bytes": "exact_configured_limit" + }, + "public_identity": "exact_expected", + "sign_event": [ + "canonical_unsigned_json", "exact_unsigned_fields", "exact_expected_author", + "computed_event_id", "valid_schnorr_signature", "canonical_signed_json", + "retain_exact_verified_bytes" + ], + "nip04": { + "direction": "exact_result_tag", + "peer": "exact_echo", + "ciphertext": "canonical_base64_aes_cbc_shape", + "encrypt_length": "exact_plaintext_padding_length", + "decrypt_length": "less_than_ciphertext_block_capacity" + }, + "nip44": { + "direction": "exact_result_tag", + "peer": "exact_echo", + "version": 2, + "plaintext_max_bytes": 65_408, + "ciphertext": "canonical_base64_v2_shape", + "encrypt_length": "exact_v2_plaintext_padding_length", + "decrypt_length": "bounded_by_ciphertext_padding_capacity" + }, + "semantic_output_max_bytes": 1_048_576, + "verified_result_sealed": true, + "verified_result_serializable": false, + "safe_errors_source_free": true, + "late_result_usable": false, + "ambiguous_result_is_publication": false, + "signing_is_publication": false, + "database_transaction_allowed": false, + "provider_execution_allowed": false + }) + ); +} + +#[test] +fn implementation_rebinds_every_response_before_semantic_use() { + for required in [ + "observed_at.get() > operation.deadline().get()", + "parts.outer_correlation_id.as_ref() != expected_correlation_id", + "response.operation_id != expected_operation_id", + "response.correlation_id != expected_correlation_id", + "response.expected_identity != operation.expected_identity().as_hex()", + "response.capability != WireCapability::from(operation.input().capability())", + "verify_peer(operation, &peer)?", + "event.verify().is_err()", + "canonical_event.as_slice() != payload.as_slice()", + "VerifiedProviderResult::SignedEvent(payload)", + "verify_nip04_ciphertext", + "verify_nip44_ciphertext", + ] { + assert!( + VERIFICATION_SOURCE.contains(required), + "missing verifier {required}" + ); + } + assert!(TRANSPORT_SOURCE.contains("pub(crate) struct LocalSignerUntrustedParts")); +} + +#[test] +fn verified_boundary_is_sealed_redacted_and_not_publication() { + for required in [ + "pub struct MycVerifiedProviderResponse", + "pub struct MycProviderResponseObservedAtUnixMs", + "pub enum MycProviderVerificationErrorKind", + "pub struct MycProviderVerificationError", + ".field(\"result\", &\"[redacted]\")", + "impl Error for MycProviderVerificationError {}", + ] { + assert!(VERIFICATION_SOURCE.contains(required)); + } + for forbidden in [ + "pub result:", + "pub payload:", + "pub outer_correlation_id:", + "pub fn into_inner", + "pub fn raw_response", + "impl Serialize for MycVerifiedProviderResponse", + "pub const fn signed_event", + "ServiceSqliteTransaction", + "publish(", + "nostr_sdk", + ] { + assert!(!VERIFICATION_SOURCE.contains(forbidden)); + } + assert!(LIB_SOURCE.contains("mod provider_verification;")); + assert!(!LIB_SOURCE.contains("pub mod provider_verification")); +} + +#[test] +fn peer_and_nip44_version_are_mandatory_response_evidence() { + let response = TRANSPORT_SOURCE + .split("pub(crate) enum WireProviderResult") + .nth(1) + .expect("response result enum"); + for variant in [ + "Nip04Encrypt", + "Nip04Decrypt", + "Nip44Encrypt", + "Nip44Decrypt", + ] { + assert!( + response.contains(&format!("{variant} {{\n peer: String,")), + "missing peer echo on {variant}" + ); + } + assert!(VERIFICATION_SOURCE.contains("version != requested_version.as_u8()")); + assert!(VERIFICATION_SOURCE.contains("decoded.first().copied() != Some(version.as_u8())")); +}