myc

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

commit 841e39d6b46781ddb060663fb6d8b12b4198d4b6
parent 5b9644dfa35ed5819090b74176d200ca86b29d07
Author: triesap <tyson@radroots.org>
Date:   Sat, 22 Aug 2026 01:33:35 +0000

feat(myc): bind exact NIP-46 work

Bind verified decrypt results and typed requests to complete provider operations. Reject unsupported methods, permissions, signing identities, and nonfuture work before provider execution while preserving transaction-free construction and canonical NIP-44 padding.

Diffstat:
Mcontracts/api_baselines/myc.txt | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Acontracts/services_hardening/nip46_work.v1.json | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 6++++++
Msrc/nip46_replay.rs | 8++++++++
Msrc/nip46_verification.rs | 12+++++++-----
Asrc/nip46_work.rs | 1143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/provider_contract.rs | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/provider_verification.rs | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Msrc/state_connection.rs | 23+++++++++++++++++++++++
Msrc/state_request.rs | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/package_boundary.rs | 56+++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Atests/services_hardening_nip46_work.rs | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
12 files changed, 1817 insertions(+), 28 deletions(-)

diff --git a/contracts/api_baselines/myc.txt b/contracts/api_baselines/myc.txt @@ -323,6 +323,19 @@ pub myc::MycNip46VerificationErrorKind::InvalidTransportBinding pub myc::MycNip46VerificationErrorKind::InvalidTypedRequest pub myc::MycNip46VerificationErrorKind::MalformedEvent pub myc::MycNip46VerificationErrorKind::NonCanonicalEvent +pub enum myc::MycNip46WorkErrorKind +pub myc::MycNip46WorkErrorKind::ConnectionExpired +pub myc::MycNip46WorkErrorKind::DecryptionRejected +pub myc::MycNip46WorkErrorKind::InvalidBinding +pub myc::MycNip46WorkErrorKind::PermissionDenied +pub myc::MycNip46WorkErrorKind::ProviderWorkRejected +pub myc::MycNip46WorkErrorKind::UnsupportedMethod +impl myc::MycNip46WorkErrorKind +pub const fn myc::MycNip46WorkErrorKind::code(self) -> &'static str +pub enum myc::MycNip46WorkKind +pub myc::MycNip46WorkKind::Connect +pub myc::MycNip46WorkKind::Local +pub myc::MycNip46WorkKind::Provider pub enum myc::MycProviderCapability pub myc::MycProviderCapability::Describe pub myc::MycProviderCapability::Nip04Decrypt @@ -692,6 +705,9 @@ impl myc::MycDecryptedIdentity pub fn myc::MycDecryptedIdentity::public_identity(&self) -> &myc::MycProviderPublicIdentity impl core::fmt::Debug for myc::MycDecryptedIdentity pub fn myc::MycDecryptedIdentity::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycDecryptedNip46Request +impl core::fmt::Debug for myc::MycDecryptedNip46Request +pub fn myc::MycDecryptedNip46Request::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct myc::MycDeliveryArtifactDigest(_) impl myc::MycDeliveryArtifactDigest pub const fn myc::MycDeliveryArtifactDigest::as_bytes(&self) -> &[u8; 32] @@ -948,6 +964,12 @@ impl myc::MycNip46ConnectionIdentity pub const fn myc::MycNip46ConnectionIdentity::as_bytes(&self) -> &[u8; 32] impl core::fmt::Debug for myc::MycNip46ConnectionIdentity pub fn myc::MycNip46ConnectionIdentity::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycNip46DecryptWork +impl myc::MycNip46DecryptWork +pub fn myc::MycNip46DecryptWork::complete(self, myc::MycVerifiedProviderResponse, myc::MycNip46AdmissionLimits) -> core::result::Result<myc::MycDecryptedNip46Request, myc::MycNip46WorkError> +pub const fn myc::MycNip46DecryptWork::operation(&self) -> &myc::MycProviderOperation +impl core::fmt::Debug for myc::MycNip46DecryptWork +pub fn myc::MycNip46DecryptWork::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct myc::MycNip46EventId(_) impl myc::MycNip46EventId pub const fn myc::MycNip46EventId::as_bytes(&self) -> &[u8; 32] @@ -981,11 +1003,36 @@ impl core::fmt::Debug for myc::MycNip46VerificationError pub fn myc::MycNip46VerificationError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::fmt::Display for myc::MycNip46VerificationError pub fn myc::MycNip46VerificationError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycNip46Work +impl myc::MycNip46Work +pub const fn myc::MycNip46Work::connection(&self) -> core::option::Option<&myc::MycConnectionRecord> +pub fn myc::MycNip46Work::connection_admission_request(&self, myc::MycConnectionPolicyGeneration, myc::MycConnectionNonce, myc::MycConnectionTimeUnixMs, core::option::Option<myc::MycConnectionTimeUnixMs>, myc::MycConnectionAdmissionPolicy, myc::MycRateRelayId) -> core::result::Result<myc::MycConnectionAdmissionRequest, myc::MycNip46WorkError> +pub const fn myc::MycNip46Work::kind(&self) -> myc::MycNip46WorkKind +pub const fn myc::MycNip46Work::method(&self) -> myc::MycSignerRequestMethod +pub const fn myc::MycNip46Work::provider_operation(&self) -> core::option::Option<&myc::MycProviderOperation> +pub const fn myc::MycNip46Work::request_record(&self) -> &myc::MycSignerRequestRecord +impl core::fmt::Debug for myc::MycNip46Work +pub fn myc::MycNip46Work::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycNip46WorkError +impl myc::MycNip46WorkError +pub const fn myc::MycNip46WorkError::code(self) -> &'static str +pub const fn myc::MycNip46WorkError::kind(self) -> myc::MycNip46WorkErrorKind +impl core::error::Error for myc::MycNip46WorkError +impl core::fmt::Debug for myc::MycNip46WorkError +pub fn myc::MycNip46WorkError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for myc::MycNip46WorkError +pub fn myc::MycNip46WorkError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct myc::MycNormalizedConfigDigest(_) impl myc::MycNormalizedConfigDigest pub const fn myc::MycNormalizedConfigDigest::as_bytes(&self) -> &[u8; 32] impl core::fmt::Debug for myc::MycNormalizedConfigDigest pub fn myc::MycNormalizedConfigDigest::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycPreparedNip46Request +impl myc::MycPreparedNip46Request +pub const fn myc::MycPreparedNip46Request::method(&self) -> myc::MycSignerRequestMethod +pub const fn myc::MycPreparedNip46Request::signer_request(&self) -> &myc::MycSignerRequest +impl core::fmt::Debug for myc::MycPreparedNip46Request +pub fn myc::MycPreparedNip46Request::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct myc::MycProviderBinding impl myc::MycProviderBinding pub const fn myc::MycProviderBinding::credential_reference(&self) -> core::option::Option<&myc::MycProviderCredentialReference> @@ -1434,6 +1481,9 @@ pub async fn myc::open_myc_state_inspection(&myc::MycRuntimeContext, &myc::MycSt pub async fn myc::open_myc_state_read_write(&myc::MycRuntimeContext, &myc::MycStateMetadata, radroots_service_sqlite::migration::MigrationAppliedAtUnixSeconds, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<myc::MycStateHost, myc::MycStateHostError> pub fn myc::parse_myc_cli_v1_from<I, T>(I) -> core::result::Result<myc::MycCliInvocationV1, myc::MycCliV1Error> where I: core::iter::traits::collect::IntoIterator<Item = T>, T: core::convert::Into<std::ffi::os_str::OsString> + core::clone::Clone pub fn myc::parse_myc_config_v1(&[u8], myc::MycConfigProfile) -> core::result::Result<myc::MycConfigDocumentV1, myc::MycConfigV1Error> +pub fn myc::prepare_myc_nip46_decrypt_work(myc::MycVerifiedNip46Event, &myc::MycProviderBinding, myc::MycProviderDeadlineUnixMs) -> core::result::Result<myc::MycNip46DecryptWork, myc::MycNip46WorkError> +pub fn myc::prepare_myc_nip46_request(myc::MycDecryptedNip46Request, myc::MycSignerOperationNonce, myc::MycRequestReceivedAtUnixMs) -> core::result::Result<myc::MycPreparedNip46Request, myc::MycNip46WorkError> +pub fn myc::prepare_myc_nip46_work(myc::MycPreparedNip46Request, myc::MycSignerRequestRecord, core::option::Option<myc::MycConnectionRecord>, &myc::MycProviderContract, myc::MycConnectionTimeUnixMs, core::option::Option<myc::MycProviderDeadlineUnixMs>) -> core::result::Result<myc::MycNip46Work, myc::MycNip46WorkError> pub fn myc::provision_myc_encrypted_identity(&myc::MycProviderBinding, &myc::MycWrappingCredential, myc::MycEncryptedIdentityProvisioningMaterial) -> core::result::Result<myc::MycDecryptedIdentity, myc::MycEncryptedIdentityEnvelopeError> pub fn myc::resolve_myc_runtime_context(&radroots_runtime_paths::roots::RadrootsPathResolver, &myc::MycCliInvocationV1) -> core::result::Result<myc::MycRuntimeContext, myc::MycRuntimeContextError> pub fn myc::resolve_myc_wrapping_credential(&myc::MycRuntimeContext, &myc::MycProviderBinding) -> core::result::Result<myc::MycWrappingCredential, myc::MycCredentialResolutionError> diff --git a/contracts/services_hardening/nip46_work.v1.json b/contracts/services_hardening/nip46_work.v1.json @@ -0,0 +1,92 @@ +{ + "schema": "radroots.myc.nip46-work.v1", + "contract_version": 1, + "prerequisites": ["nip46_verification.v1", "nip46_replay.v1", "nip46_authorization.v1"], + "decryption": { + "event_binding": "exact_verified_transport_receiver_client_and_encryption_context", + "operation_id": "sha256_domain_and_verified_event_id", + "operation_id_domain": "radroots.myc.nip46.decrypt.operation.v1\\0", + "correlation_id": "sha256_domain_and_verified_event_id", + "correlation_id_domain": "radroots.myc.nip46.decrypt.correlation.v1\\0", + "provider_role": "transport", + "nip04_capability": "nip04_decrypt", + "nip44_capability": "nip44_decrypt_v2", + "plaintext_authority": "independently_verified_exact_provider_operation_result", + "provider_result_binding": { + "algorithm": "sha256", + "domain": "radroots.myc.provider.operation_binding.v1\\0", + "field_order": [ + "role", + "instance", + "provider", + "operation_id", + "correlation_id", + "absolute_deadline_unix_ms", + "expected_identity", + "capability", + "peer_or_empty", + "nip44_version_or_zero", + "input_or_empty" + ], + "utf8_and_byte_framing": "u64_be_length_then_exact_bytes", + "operation_id_encoding": "exact_32_bytes", + "correlation_id_encoding": "exact_32_bytes", + "deadline_encoding": "u64_be", + "nip44_version_encoding": "one_u8_zero_when_absent" + }, + "alternate_plaintext_to_work_path": "forbidden" + }, + "method_inventory": [ + "connect", + "get_public_key", + "get_session_capability", + "sign_event", + "nip04_encrypt", + "nip04_decrypt", + "nip44_encrypt", + "nip44_decrypt", + "ping", + "switch_relays", + "logout" + ], + "custom_methods": "unsupported_v1", + "work_classes": { + "connect": "typed_connection_admission_input", + "local": ["get_public_key", "get_session_capability", "ping", "switch_relays", "logout"], + "provider": { + "role": "user", + "sign_event_input": [ + "canonical_unsigned_json", + "valid_optional_event_id", + "exact_configured_user_author" + ], + "mappings": { + "sign_event": "sign_event", + "nip04_encrypt": "nip04_encrypt", + "nip04_decrypt": "nip04_decrypt", + "nip44_encrypt": "nip44_encrypt_v2", + "nip44_decrypt": "nip44_decrypt_v2" + } + } + }, + "authorization": { + "durable_request_record_exactly_bound": true, + "non_connect_requires_active_client_bound_connection": true, + "expiry_checked_at_injected_observation_time": true, + "required_permission_api": "radroots_nostr_connect::server::required_permission", + "granted_permission_required": true, + "connect_secret_authority": "forbidden", + "client_metadata_authority": "none" + }, + "sqlite": { + "transaction_parameter": "forbidden", + "provider_execution_while_transaction_open": "forbidden", + "work_construction_phase": "after_durable_request_admission_returns" + }, + "nonclaims": [ + "provider_execution", + "response_commit", + "outbox_creation", + "relay_publication" + ] +} diff --git a/src/lib.rs b/src/lib.rs @@ -7,6 +7,7 @@ mod nip46_admission; mod nip46_authorization; mod nip46_replay; mod nip46_verification; +mod nip46_work; mod provider_contract; mod provider_credential; mod provider_envelope; @@ -49,6 +50,11 @@ pub use nip46_verification::{ MycNip46VerificationError, MycNip46VerificationErrorKind, MycVerifiedNip46Event, MycVerifiedNip46Request, verify_myc_nip46_event, verify_myc_nip46_request, }; +pub use nip46_work::{ + MycDecryptedNip46Request, MycNip46DecryptWork, MycNip46Work, MycNip46WorkError, + MycNip46WorkErrorKind, MycNip46WorkKind, MycPreparedNip46Request, + prepare_myc_nip46_decrypt_work, prepare_myc_nip46_request, prepare_myc_nip46_work, +}; pub use provider_contract::{ MYC_PROVIDER_CONCURRENCY_MAX, MYC_PROVIDER_CONTRACT_VERSION, MYC_PROVIDER_INPUT_MAX_BYTES, MYC_PROVIDER_OUTPUT_MAX_BYTES, MYC_PROVIDER_REQUEST_DEADLINE_MAX_MS, diff --git a/src/nip46_replay.rs b/src/nip46_replay.rs @@ -116,6 +116,14 @@ impl MycReplayBoundNip46Request { self.request.request_id() } + pub(crate) fn method(&self) -> &str { + self.request.method() + } + + pub(crate) fn canonical_request(&self) -> &[u8] { + self.request.canonical_request() + } + /// Projects the digest-only replay key without retaining protected content. #[must_use] pub const fn replay_key(&self) -> MycNip46ReplayKey { diff --git a/src/nip46_verification.rs b/src/nip46_verification.rs @@ -504,10 +504,12 @@ fn is_valid_nip44_padding_length(length: usize) -> bool { return false; } if length <= 256 { - return length.is_power_of_two(); + return length.is_multiple_of(32); } - let exponent = usize::BITS - 1 - length.leading_zeros(); - let chunk = 1usize << (exponent - 3); + let Some(next_power) = length.checked_next_power_of_two() else { + return false; + }; + let chunk = next_power / 8; length.is_multiple_of(chunk) } @@ -536,10 +538,10 @@ mod tests { #[test] fn every_protocol_padding_boundary_is_closed() { - for valid in [32, 64, 128, 256, 288, 320, 65_536] { + for valid in [32, 64, 96, 128, 160, 192, 224, 256, 320, 384, 65_536] { assert!(is_valid_nip44_padding_length(valid)); } - for invalid in [0, 31, 33, 257, 287, 65_535, 65_537] { + for invalid in [0, 31, 33, 257, 287, 288, 65_535, 65_537] { assert!(!is_valid_nip44_padding_length(invalid)); } } diff --git a/src/nip46_work.rs b/src/nip46_work.rs @@ -0,0 +1,1143 @@ +//! Transaction-free NIP-46 decryption, method admission, and provider work. + +use core::fmt; +use std::error::Error; + +use nostr::UnsignedEvent as NostrUnsignedEvent; +use radroots_nostr_connect::{ + message::{Request, RequestMessage, UnsignedEvent as ConnectUnsignedEvent}, + server::required_permission, +}; +use sha2::{Digest, Sha256}; + +use crate::{ + MycConnectionAdmissionPolicy, MycConnectionAdmissionRequest, MycConnectionNonce, + MycConnectionPermission, MycConnectionPermissionSet, MycConnectionPolicyGeneration, + MycConnectionRecord, MycConnectionStatus, MycConnectionTimeUnixMs, MycNip46AdmissionLimits, + MycNip46EncryptionContext, MycProviderBinding, MycProviderContract, MycProviderCorrelationId, + MycProviderDeadlineUnixMs, MycProviderNip44Version, MycProviderOperation, + MycProviderOperationId, MycProviderOperationInput, MycProviderPublicIdentity, MycProviderRole, + MycRateRelayId, MycReplayBoundNip46Request, MycRequestReceivedAtUnixMs, + MycSignerOperationNonce, MycSignerRequest, MycSignerRequestMethod, MycSignerRequestRecord, + MycVerifiedNip46Event, MycVerifiedProviderResponse, admit_myc_nip46_request, + bind_myc_nip46_replay, verify_myc_nip46_request, +}; + +const DECRYPT_OPERATION_ID_DOMAIN: &[u8] = b"radroots.myc.nip46.decrypt.operation.v1\0"; +const DECRYPT_CORRELATION_ID_DOMAIN: &[u8] = b"radroots.myc.nip46.decrypt.correlation.v1\0"; + +/// Stable source-free Step 145 failure classification. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycNip46WorkErrorKind { + InvalidBinding, + DecryptionRejected, + UnsupportedMethod, + PermissionDenied, + ConnectionExpired, + ProviderWorkRejected, +} + +impl MycNip46WorkErrorKind { + /// Returns the stable machine-facing safe code. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::InvalidBinding => "nip46_work_binding_invalid", + Self::DecryptionRejected => "nip46_decryption_rejected", + Self::UnsupportedMethod => "nip46_method_unsupported", + Self::PermissionDenied => "nip46_permission_denied", + Self::ConnectionExpired => "nip46_connection_inactive", + Self::ProviderWorkRejected => "nip46_provider_work_rejected", + } + } + + const fn message(self) -> &'static str { + match self { + Self::InvalidBinding => "NIP-46 work binding is invalid", + Self::DecryptionRejected => "NIP-46 decrypted request was rejected", + Self::UnsupportedMethod => "NIP-46 method is unsupported", + Self::PermissionDenied => "NIP-46 permission was denied", + Self::ConnectionExpired => "NIP-46 connection is not active", + Self::ProviderWorkRejected => "NIP-46 provider work was rejected", + } + } +} + +/// One redacted source-free Step 145 failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct MycNip46WorkError { + kind: MycNip46WorkErrorKind, +} + +impl MycNip46WorkError { + /// Returns the stable failure classification. + #[must_use] + pub const fn kind(self) -> MycNip46WorkErrorKind { + 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 MycNip46WorkError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycNip46WorkError") + .field("kind", &self.kind) + .finish() + } +} + +impl fmt::Display for MycNip46WorkError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.kind.message()) + } +} + +impl Error for MycNip46WorkError {} + +const fn work_error(kind: MycNip46WorkErrorKind) -> MycNip46WorkError { + MycNip46WorkError { kind } +} + +/// Provider-bound work that must complete before plaintext request admission. +pub struct MycNip46DecryptWork { + event: MycVerifiedNip46Event, + operation: MycProviderOperation, +} + +impl MycNip46DecryptWork { + /// Returns the exact transport-provider operation for external execution. + #[must_use] + pub const fn operation(&self) -> &MycProviderOperation { + &self.operation + } + + /// Binds an independently verified provider result to the exact event. + pub fn complete( + self, + response: MycVerifiedProviderResponse, + limits: MycNip46AdmissionLimits, + ) -> Result<MycDecryptedNip46Request, MycNip46WorkError> { + let expected_capability = self.operation.input().capability(); + if response.operation_id() != self.operation.operation_id() + || response.correlation_id() != self.operation.correlation_id() + || response.instance() != self.operation.instance() + || response.role() != MycProviderRole::Transport + || response.capability() != expected_capability + || response.nip44_version() != self.operation.input().nip44_version() + || !response.matches_operation(&self.operation) + { + return Err(work_error(MycNip46WorkErrorKind::InvalidBinding)); + } + let plaintext = response + .protected_payload() + .ok_or_else(|| work_error(MycNip46WorkErrorKind::DecryptionRejected))?; + let bounded = admit_myc_nip46_request(limits, plaintext) + .map_err(|_| work_error(MycNip46WorkErrorKind::DecryptionRejected))?; + let request = verify_myc_nip46_request(bounded) + .map_err(|_| work_error(MycNip46WorkErrorKind::DecryptionRejected))?; + let replay = bind_myc_nip46_replay(self.event, request) + .map_err(|_| work_error(MycNip46WorkErrorKind::DecryptionRejected))?; + Ok(MycDecryptedNip46Request { replay }) + } +} + +impl fmt::Debug for MycNip46DecryptWork { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycNip46DecryptWork") + .field("event", &"[redacted]") + .field("operation", &self.operation) + .finish() + } +} + +/// Constructs the exact transport-provider decryption work for one verified event. +pub fn prepare_myc_nip46_decrypt_work( + event: MycVerifiedNip46Event, + transport: &MycProviderBinding, + deadline: MycProviderDeadlineUnixMs, +) -> Result<MycNip46DecryptWork, MycNip46WorkError> { + if transport.role() != MycProviderRole::Transport + || transport.expected_identity() != event.receiver_public_key() + { + return Err(work_error(MycNip46WorkErrorKind::InvalidBinding)); + } + let operation_id = MycProviderOperationId::from_bytes(derive_decrypt_identity( + DECRYPT_OPERATION_ID_DOMAIN, + event.event_id().as_bytes(), + )); + let correlation_id = MycProviderCorrelationId::from_bytes(derive_decrypt_identity( + DECRYPT_CORRELATION_ID_DOMAIN, + event.event_id().as_bytes(), + )); + let peer = MycProviderPublicIdentity::new(event.client_public_key().as_hex()) + .map_err(|_| work_error(MycNip46WorkErrorKind::InvalidBinding))?; + let input = match event.encryption_context() { + MycNip46EncryptionContext::Nip04 => { + MycProviderOperationInput::nip04_decrypt(peer, event.encrypted_content().as_bytes()) + } + MycNip46EncryptionContext::Nip44V2 => MycProviderOperationInput::nip44_decrypt( + peer, + MycProviderNip44Version::V2, + event.encrypted_content().as_bytes(), + ), + } + .map_err(|_| work_error(MycNip46WorkErrorKind::ProviderWorkRejected))?; + let operation = + MycProviderOperation::new(transport, operation_id, correlation_id, deadline, input) + .map_err(|_| work_error(MycNip46WorkErrorKind::ProviderWorkRejected))?; + Ok(MycNip46DecryptWork { event, operation }) +} + +fn derive_decrypt_identity(domain: &[u8], event_id: &[u8; 32]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(domain); + hasher.update(event_id); + hasher.finalize().into() +} + +/// Non-forgeable proof that the typed request came from the exact decrypt work. +pub struct MycDecryptedNip46Request { + replay: MycReplayBoundNip46Request, +} + +impl fmt::Debug for MycDecryptedNip46Request { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MycDecryptedNip46Request([redacted])") + } +} + +/// Exact request ready for durable admission but not provider execution. +pub struct MycPreparedNip46Request { + signer_request: MycSignerRequest, + request: Request, +} + +impl MycPreparedNip46Request { + /// Returns the exact input for the short durable request-admission transaction. + #[must_use] + pub const fn signer_request(&self) -> &MycSignerRequest { + &self.signer_request + } + + /// Returns the exact closed method admitted before persistence. + #[must_use] + pub const fn method(&self) -> MycSignerRequestMethod { + self.signer_request.method() + } +} + +impl fmt::Debug for MycPreparedNip46Request { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycPreparedNip46Request") + .field("method", &self.method()) + .field("request", &"[redacted]") + .finish() + } +} + +/// Converts one proven decrypted request into exact durable-admission input. +pub fn prepare_myc_nip46_request( + decrypted: MycDecryptedNip46Request, + nonce: MycSignerOperationNonce, + received_at: MycRequestReceivedAtUnixMs, +) -> Result<MycPreparedNip46Request, MycNip46WorkError> { + let method = MycSignerRequestMethod::parse(decrypted.replay.method()) + .ok_or_else(|| work_error(MycNip46WorkErrorKind::UnsupportedMethod))?; + let message: RequestMessage = serde_json::from_slice(decrypted.replay.canonical_request()) + .map_err(|_| work_error(MycNip46WorkErrorKind::InvalidBinding))?; + if message.payload().method().as_str() != method.as_str() + || message + .request_id() + .map(|request_id| request_id.as_str() != decrypted.replay.request_id().as_str()) + .unwrap_or(true) + { + return Err(work_error(MycNip46WorkErrorKind::InvalidBinding)); + } + let signer_request = MycSignerRequest::new( + decrypted.replay.client_public_key().clone(), + decrypted.replay.request_id().clone(), + decrypted.replay.event_id(), + method, + decrypted.replay.request_digest(), + nonce, + received_at, + ); + Ok(MycPreparedNip46Request { + signer_request, + request: message.request, + }) +} + +/// Exact Step 145 work classification. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycNip46WorkKind { + Connect, + Local, + Provider, +} + +enum Nip46WorkPayload { + Connect { + client: crate::MycNip46ClientPublicKey, + permissions: MycConnectionPermissionSet, + }, + Local, + Provider(MycProviderOperation), +} + +/// Sealed request work constructed only after durable request admission. +pub struct MycNip46Work { + request: MycSignerRequestRecord, + connection: Option<MycConnectionRecord>, + method: MycSignerRequestMethod, + payload: Nip46WorkPayload, +} + +impl MycNip46Work { + /// Returns the exact admitted durable request record. + #[must_use] + pub const fn request_record(&self) -> &MycSignerRequestRecord { + &self.request + } + + /// Returns the exact active connection used for non-connect work. + #[must_use] + pub const fn connection(&self) -> Option<&MycConnectionRecord> { + self.connection.as_ref() + } + + /// Returns the exact admitted request method. + #[must_use] + pub const fn method(&self) -> MycSignerRequestMethod { + self.method + } + + /// Returns the work class without exposing protected parameters. + #[must_use] + pub const fn kind(&self) -> MycNip46WorkKind { + match &self.payload { + Nip46WorkPayload::Connect { .. } => MycNip46WorkKind::Connect, + Nip46WorkPayload::Local => MycNip46WorkKind::Local, + Nip46WorkPayload::Provider(_) => MycNip46WorkKind::Provider, + } + } + + /// Returns the provider operation only for provider-bearing work. + #[must_use] + pub const fn provider_operation(&self) -> Option<&MycProviderOperation> { + match &self.payload { + Nip46WorkPayload::Provider(operation) => Some(operation), + Nip46WorkPayload::Connect { .. } | Nip46WorkPayload::Local => None, + } + } + + /// Constructs the exact Step 144 admission request for connect work. + pub fn connection_admission_request( + &self, + policy_generation: MycConnectionPolicyGeneration, + nonce: MycConnectionNonce, + observed_at: MycConnectionTimeUnixMs, + authorized_until: Option<MycConnectionTimeUnixMs>, + policy: MycConnectionAdmissionPolicy, + relay_id: MycRateRelayId, + ) -> Result<MycConnectionAdmissionRequest, MycNip46WorkError> { + let Nip46WorkPayload::Connect { + client, + permissions, + } = &self.payload + else { + return Err(work_error(MycNip46WorkErrorKind::InvalidBinding)); + }; + MycConnectionAdmissionRequest::new( + self.request.operation_id(), + client.clone(), + permissions.clone(), + policy_generation, + nonce, + observed_at, + authorized_until, + policy, + relay_id, + ) + .map_err(|_| work_error(MycNip46WorkErrorKind::InvalidBinding)) + } +} + +impl fmt::Debug for MycNip46Work { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycNip46Work") + .field("method", &self.method) + .field("kind", &self.kind()) + .field("request", &"[redacted]") + .field( + "connection", + &self.connection.as_ref().map(|_| "[redacted]"), + ) + .finish() + } +} + +/// Binds an admitted request and optional active connection to exact work. +/// +/// This function accepts no repository, host, transaction, provider executor, +/// clock, or entropy source. Provider execution therefore starts only after +/// the caller's durable request-admission transaction has ended. +pub fn prepare_myc_nip46_work( + prepared: MycPreparedNip46Request, + record: MycSignerRequestRecord, + connection: Option<MycConnectionRecord>, + providers: &MycProviderContract, + observed_at: MycConnectionTimeUnixMs, + provider_deadline: Option<MycProviderDeadlineUnixMs>, +) -> Result<MycNip46Work, MycNip46WorkError> { + let MycPreparedNip46Request { + signer_request, + request, + } = prepared; + if !record.matches_request(&signer_request) || observed_at.get() < record.received_at().get() { + return Err(work_error(MycNip46WorkErrorKind::InvalidBinding)); + } + let method = signer_request.method(); + let request = match request { + Request::Connect { + remote_signer_public_key, + secret, + requested_permissions, + client_metadata: _, + } => { + if connection.is_some() || provider_deadline.is_some() || secret.is_some() { + return Err(work_error(MycNip46WorkErrorKind::InvalidBinding)); + } + let user = user_binding(providers)?; + if remote_signer_public_key.to_hex() != user.expected_identity().as_hex() { + return Err(work_error(MycNip46WorkErrorKind::InvalidBinding)); + } + let permissions = connect_permissions(requested_permissions.as_slice())?; + return Ok(MycNip46Work { + request: record, + connection: None, + method, + payload: Nip46WorkPayload::Connect { + client: signer_request.client_public_key().clone(), + permissions, + }, + }); + } + Request::Custom { .. } => { + return Err(work_error(MycNip46WorkErrorKind::UnsupportedMethod)); + } + request => request, + }; + + let connection = connection.ok_or_else(|| work_error(MycNip46WorkErrorKind::InvalidBinding))?; + if connection.client_public_key() != signer_request.client_public_key() { + return Err(work_error(MycNip46WorkErrorKind::InvalidBinding)); + } + if connection.status() != MycConnectionStatus::Active + || connection + .authorized_until() + .is_some_and(|until| observed_at > until) + { + return Err(work_error(MycNip46WorkErrorKind::ConnectionExpired)); + } + if let Some(permission) = required_permission(&request) { + let permission = protocol_permission(&permission.to_string())?; + if !connection.granted_permissions().contains(permission) { + return Err(work_error(MycNip46WorkErrorKind::PermissionDenied)); + } + } + + let payload = match request { + Request::GetPublicKey + | Request::GetSessionCapability + | Request::Ping + | Request::SwitchRelays + | Request::Logout => { + if provider_deadline.is_some() { + return Err(work_error(MycNip46WorkErrorKind::InvalidBinding)); + } + Nip46WorkPayload::Local + } + Request::SignEvent(event) => { + let input = sign_event_input(&event, providers)?; + provider_work( + providers, + &record, + observed_at, + provider_deadline, + Ok(input), + )? + } + Request::Nip04Encrypt { + public_key, + plaintext, + } => provider_work( + providers, + &record, + observed_at, + provider_deadline, + MycProviderOperationInput::nip04_encrypt( + provider_identity(&public_key.to_hex())?, + plaintext.as_bytes(), + ), + )?, + Request::Nip04Decrypt { + public_key, + ciphertext, + } => provider_work( + providers, + &record, + observed_at, + provider_deadline, + MycProviderOperationInput::nip04_decrypt( + provider_identity(&public_key.to_hex())?, + ciphertext.as_bytes(), + ), + )?, + Request::Nip44Encrypt { + public_key, + plaintext, + } => provider_work( + providers, + &record, + observed_at, + provider_deadline, + MycProviderOperationInput::nip44_encrypt( + provider_identity(&public_key.to_hex())?, + MycProviderNip44Version::V2, + plaintext.as_bytes(), + ), + )?, + Request::Nip44Decrypt { + public_key, + ciphertext, + } => provider_work( + providers, + &record, + observed_at, + provider_deadline, + MycProviderOperationInput::nip44_decrypt( + provider_identity(&public_key.to_hex())?, + MycProviderNip44Version::V2, + ciphertext.as_bytes(), + ), + )?, + Request::Connect { .. } | Request::Custom { .. } => { + return Err(work_error(MycNip46WorkErrorKind::UnsupportedMethod)); + } + }; + + Ok(MycNip46Work { + request: record, + connection: Some(connection), + method, + payload, + }) +} + +fn provider_work( + providers: &MycProviderContract, + record: &MycSignerRequestRecord, + observed_at: MycConnectionTimeUnixMs, + deadline: Option<MycProviderDeadlineUnixMs>, + input: Result<MycProviderOperationInput, crate::MycProviderContractError>, +) -> Result<Nip46WorkPayload, MycNip46WorkError> { + let deadline = deadline.ok_or_else(|| work_error(MycNip46WorkErrorKind::InvalidBinding))?; + if deadline.get() <= observed_at.get() { + return Err(work_error(MycNip46WorkErrorKind::ProviderWorkRejected)); + } + let input = input.map_err(|_| work_error(MycNip46WorkErrorKind::ProviderWorkRejected))?; + let operation = MycProviderOperation::new( + user_binding(providers)?, + MycProviderOperationId::from_bytes(*record.operation_id().as_bytes()), + MycProviderCorrelationId::from_bytes(*record.correlation_id().as_bytes()), + deadline, + input, + ) + .map_err(|_| work_error(MycNip46WorkErrorKind::ProviderWorkRejected))?; + Ok(Nip46WorkPayload::Provider(operation)) +} + +fn user_binding(providers: &MycProviderContract) -> Result<&MycProviderBinding, MycNip46WorkError> { + providers + .binding(MycProviderRole::User) + .ok_or_else(|| work_error(MycNip46WorkErrorKind::InvalidBinding)) +} + +fn sign_event_input( + event: &ConnectUnsignedEvent, + providers: &MycProviderContract, +) -> Result<MycProviderOperationInput, MycNip46WorkError> { + let canonical = event.as_json(); + let unsigned: NostrUnsignedEvent = serde_json::from_str(&canonical) + .map_err(|_| work_error(MycNip46WorkErrorKind::ProviderWorkRejected))?; + let reencoded = serde_json::to_vec(&unsigned) + .map_err(|_| work_error(MycNip46WorkErrorKind::ProviderWorkRejected))?; + if reencoded.as_slice() != canonical.as_bytes() + || unsigned.verify_id().is_err() + || unsigned.pubkey.to_hex() != user_binding(providers)?.expected_identity().as_hex() + { + return Err(work_error(MycNip46WorkErrorKind::ProviderWorkRejected)); + } + MycProviderOperationInput::sign_event(canonical.as_bytes()) + .map_err(|_| work_error(MycNip46WorkErrorKind::ProviderWorkRejected)) +} + +fn provider_identity(value: &str) -> Result<MycProviderPublicIdentity, MycNip46WorkError> { + MycProviderPublicIdentity::new(value) + .map_err(|_| work_error(MycNip46WorkErrorKind::ProviderWorkRejected)) +} + +fn protocol_permission(value: &str) -> Result<MycConnectionPermission, MycNip46WorkError> { + MycConnectionPermission::parse(value) + .ok_or_else(|| work_error(MycNip46WorkErrorKind::UnsupportedMethod)) +} + +fn connect_permissions( + permissions: &[radroots_nostr_connect::permission::Permission], +) -> Result<MycConnectionPermissionSet, MycNip46WorkError> { + let permissions = permissions + .iter() + .map(|permission| protocol_permission(&permission.to_string())) + .collect::<Result<Vec<_>, _>>()?; + if permissions.iter().any(|permission| { + !matches!( + permission, + MycConnectionPermission::SignEvent(_) + | MycConnectionPermission::Nip04Encrypt + | MycConnectionPermission::Nip04Decrypt + | MycConnectionPermission::Nip44Encrypt + | MycConnectionPermission::Nip44Decrypt + | MycConnectionPermission::SwitchRelays + ) + }) { + return Err(work_error(MycNip46WorkErrorKind::PermissionDenied)); + } + MycConnectionPermissionSet::new(&permissions) + .map_err(|_| work_error(MycNip46WorkErrorKind::PermissionDenied)) +} + +#[cfg(test)] +mod tests { + use std::error::Error as _; + + use nostr::{ + EventBuilder, JsonUtil as _, Keys, Kind, Tag, Timestamp, + UnsignedEvent as NostrUnsignedEvent, + nips::{nip04, nip44}, + }; + use radroots_nostr_connect::{ + Method, + message::{Request, RequestMessage, UnsignedEvent as ConnectUnsignedEvent}, + }; + + use crate::{ + MycConfigDocumentV1, MycConfigProfile, MycNip46AuthoredTimePolicy, + MycNip46ObservedAtUnixSeconds, MycProviderCapability, MycProviderResponseObservedAtUnixMs, + admit_myc_nip46_event, parse_myc_config_v1, + provider_verification::verify_protected_response_for_test, verify_myc_nip46_event, + }; + + use super::*; + + const CONFIG: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); + const OBSERVED_AT_SECONDS: u64 = 1_725_000_000; + const RECEIVED_AT_MS: u64 = 1_725_000_000_000; + const PROVIDER_DEADLINE_MS: u64 = RECEIVED_AT_MS + 10_000; + + fn keys(seed: u8) -> Keys { + Keys::parse(&format!("{seed:02x}{}", "00".repeat(31))).expect("test keys") + } + + fn configuration() -> MycConfigDocumentV1 { + let source = CONFIG + .replacen( + "4444444444444444444444444444444444444444444444444444444444444444", + &keys(2).public_key().to_hex(), + 1, + ) + .replacen( + "2222222222222222222222222222222222222222222222222222222222222222", + &keys(3).public_key().to_hex(), + 1, + ); + parse_myc_config_v1(source.as_bytes(), MycConfigProfile::RepoLocal) + .expect("test configuration") + } + + fn verified_event( + request: &Request, + nip44_v2: bool, + created_at: u64, + ) -> (MycVerifiedNip46Event, Vec<u8>) { + let client = keys(1); + let transport = keys(2); + let message = RequestMessage::try_new(format!("request-{created_at}"), request.clone()) + .expect("request message"); + let plaintext = serde_json::to_vec(&message).expect("canonical request"); + let content = if nip44_v2 { + nip44::encrypt( + client.secret_key(), + &transport.public_key(), + &plaintext, + nip44::Version::V2, + ) + .expect("NIP-44 encryption") + } else { + nip04::encrypt(client.secret_key(), &transport.public_key(), &plaintext) + .expect("NIP-04 encryption") + }; + let event = EventBuilder::new(Kind::Custom(24_133), content) + .tag(Tag::public_key(transport.public_key())) + .custom_created_at(Timestamp::from_secs(created_at)) + .sign_with_keys(&client) + .expect("signed request event"); + let config = configuration(); + let limits = MycNip46AdmissionLimits::from_config(&config).expect("admission limits"); + let wire = serde_json::to_vec(&event).expect("event wire"); + let event = verify_myc_nip46_event( + admit_myc_nip46_event(limits, &wire).expect("bounded event"), + config + .provider_contract() + .binding(MycProviderRole::Transport) + .expect("transport binding"), + MycNip46ObservedAtUnixSeconds::new(OBSERVED_AT_SECONDS).expect("observation"), + MycNip46AuthoredTimePolicy::new(120, 120).expect("authored-time policy"), + ) + .expect("verified event"); + (event, plaintext) + } + + fn decrypted(request: &Request, nip44_v2: bool, created_at: u64) -> MycDecryptedNip46Request { + let (event, plaintext) = verified_event(request, nip44_v2, created_at); + let config = configuration(); + let transport = config + .provider_contract() + .binding(MycProviderRole::Transport) + .expect("transport binding"); + let work = prepare_myc_nip46_decrypt_work( + event, + transport, + MycProviderDeadlineUnixMs::new(PROVIDER_DEADLINE_MS).expect("deadline"), + ) + .expect("decrypt work"); + let response = verify_protected_response_for_test(transport, work.operation(), &plaintext) + .expect("verified provider response"); + work.complete( + response, + MycNip46AdmissionLimits::from_config(&config).expect("admission limits"), + ) + .expect("decrypted request") + } + + fn prepared_request(request: Request, seed: u8) -> MycPreparedNip46Request { + prepare_myc_nip46_request( + decrypted(&request, true, OBSERVED_AT_SECONDS + u64::from(seed)), + MycSignerOperationNonce::from_injected_entropy([seed; 32]), + MycRequestReceivedAtUnixMs::new(RECEIVED_AT_MS + u64::from(seed)) + .expect("received time"), + ) + .expect("prepared request") + } + + fn active_connection( + prepared: &MycPreparedNip46Request, + permissions: &[MycConnectionPermission], + authorized_until: Option<u64>, + ) -> MycConnectionRecord { + MycConnectionRecord::active_for_test( + prepared.signer_request.client_public_key().clone(), + MycConnectionPermissionSet::new(permissions).expect("permissions"), + authorized_until + .map(|value| MycConnectionTimeUnixMs::new(value).expect("authorization deadline")), + ) + } + + fn unsigned_sign_event() -> ConnectUnsignedEvent { + unsigned_sign_event_for(3) + } + + fn unsigned_sign_event_for(author_seed: u8) -> ConnectUnsignedEvent { + let event = NostrUnsignedEvent::new( + keys(author_seed).public_key(), + Timestamp::from_secs(OBSERVED_AT_SECONDS), + Kind::TextNote, + Vec::<Tag>::new(), + "bounded test event", + ); + ConnectUnsignedEvent::from_json(&event.as_json()).expect("connect unsigned event") + } + + fn protocol_request(method: Method, params: Vec<String>) -> Request { + Request::from_parts(method, params).expect("protocol request") + } + + #[test] + fn verified_decryption_is_bound_to_the_exact_event_and_context() { + let request = Request::Ping; + for (nip44_v2, expected_capability, expected_version) in [ + (false, MycProviderCapability::Nip04Decrypt, None), + ( + true, + MycProviderCapability::Nip44Decrypt, + Some(MycProviderNip44Version::V2), + ), + ] { + let (event, plaintext) = verified_event(&request, nip44_v2, OBSERVED_AT_SECONDS); + let config = configuration(); + let transport = config + .provider_contract() + .binding(MycProviderRole::Transport) + .expect("transport binding"); + let work = prepare_myc_nip46_decrypt_work( + event, + transport, + MycProviderDeadlineUnixMs::new(PROVIDER_DEADLINE_MS).expect("deadline"), + ) + .expect("decrypt work"); + assert_eq!(work.operation().role(), MycProviderRole::Transport); + assert_eq!(work.operation().input().capability(), expected_capability); + assert_eq!(work.operation().input().nip44_version(), expected_version); + let response = + verify_protected_response_for_test(transport, work.operation(), &plaintext) + .expect("verified response"); + work.complete( + response, + MycNip46AdmissionLimits::from_config(&config).expect("limits"), + ) + .expect("exact event completion"); + } + + let (first_event, plaintext) = verified_event(&request, true, OBSERVED_AT_SECONDS + 1); + let (second_event, _) = verified_event(&request, true, OBSERVED_AT_SECONDS + 2); + let config = configuration(); + let transport = config + .provider_contract() + .binding(MycProviderRole::Transport) + .expect("transport binding"); + let first = prepare_myc_nip46_decrypt_work( + first_event, + transport, + MycProviderDeadlineUnixMs::new(PROVIDER_DEADLINE_MS).expect("deadline"), + ) + .expect("first work"); + let second = prepare_myc_nip46_decrypt_work( + second_event, + transport, + MycProviderDeadlineUnixMs::new(PROVIDER_DEADLINE_MS).expect("deadline"), + ) + .expect("second work"); + let response = verify_protected_response_for_test(transport, first.operation(), &plaintext) + .expect("first response"); + assert_ne!( + first.operation().operation_id(), + second.operation().operation_id() + ); + assert_eq!( + second + .complete( + response, + MycNip46AdmissionLimits::from_config(&config).expect("limits"), + ) + .expect_err("cross-event response"), + work_error(MycNip46WorkErrorKind::InvalidBinding) + ); + } + + #[test] + fn exact_method_inventory_maps_to_local_and_user_provider_work() { + let peer = keys(4).public_key().to_hex(); + let cases = vec![ + (Request::GetPublicKey, None, MycNip46WorkKind::Local, None), + ( + Request::GetSessionCapability, + None, + MycNip46WorkKind::Local, + None, + ), + (Request::Ping, None, MycNip46WorkKind::Local, None), + (Request::Logout, None, MycNip46WorkKind::Local, None), + ( + Request::SwitchRelays, + Some(MycConnectionPermission::SwitchRelays), + MycNip46WorkKind::Local, + None, + ), + ( + Request::SignEvent(unsigned_sign_event()), + Some(MycConnectionPermission::SignEvent(1)), + MycNip46WorkKind::Provider, + Some(MycProviderCapability::SignEvent), + ), + ( + protocol_request(Method::Nip04Encrypt, vec![peer.clone(), "bounded".into()]), + Some(MycConnectionPermission::Nip04Encrypt), + MycNip46WorkKind::Provider, + Some(MycProviderCapability::Nip04Encrypt), + ), + ( + protocol_request( + Method::Nip04Decrypt, + vec![ + peer.clone(), + "QUFBQUFBQUFBQUFBQUFBQQ==?iv=QUFBQUFBQUFBQUFBQUFBQQ==".into(), + ], + ), + Some(MycConnectionPermission::Nip04Decrypt), + MycNip46WorkKind::Provider, + Some(MycProviderCapability::Nip04Decrypt), + ), + ( + protocol_request(Method::Nip44Encrypt, vec![peer.clone(), "bounded".into()]), + Some(MycConnectionPermission::Nip44Encrypt), + MycNip46WorkKind::Provider, + Some(MycProviderCapability::Nip44Encrypt), + ), + ( + protocol_request( + Method::Nip44Decrypt, + vec![ + peer, + "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".into(), + ], + ), + Some(MycConnectionPermission::Nip44Decrypt), + MycNip46WorkKind::Provider, + Some(MycProviderCapability::Nip44Decrypt), + ), + ]; + for (index, (request, permission, expected_kind, expected_capability)) in + cases.into_iter().enumerate() + { + let prepared = prepared_request(request, u8::try_from(index + 1).expect("seed")); + let record = prepared.signer_request.admitted_record_for_test(); + let permissions = permission.into_iter().collect::<Vec<_>>(); + let connection = + active_connection(&prepared, &permissions, Some(RECEIVED_AT_MS + 100_000)); + let provider_deadline = (expected_kind == MycNip46WorkKind::Provider) + .then(|| MycProviderDeadlineUnixMs::new(PROVIDER_DEADLINE_MS).expect("deadline")); + let work = prepare_myc_nip46_work( + prepared, + record, + Some(connection), + configuration().provider_contract(), + MycConnectionTimeUnixMs::new(RECEIVED_AT_MS + 1_000).expect("observation"), + provider_deadline, + ) + .expect("governed work"); + assert_eq!(work.kind(), expected_kind); + assert_eq!( + work.provider_operation() + .map(|operation| operation.input().capability()), + expected_capability + ); + if let Some(operation) = work.provider_operation() { + assert_eq!(operation.role(), MycProviderRole::User); + assert_eq!( + operation.operation_id().as_bytes(), + work.request_record().operation_id().as_bytes() + ); + } + } + } + + #[test] + fn connect_and_nonconnect_authorization_fail_closed() { + let connect = protocol_request( + Method::Connect, + vec![ + keys(3).public_key().to_hex(), + String::new(), + "nip44_encrypt,sign_event:kind:1".into(), + ], + ); + let prepared = prepared_request(connect, 20); + let record = prepared.signer_request.admitted_record_for_test(); + let work = prepare_myc_nip46_work( + prepared, + record, + None, + configuration().provider_contract(), + MycConnectionTimeUnixMs::new(RECEIVED_AT_MS + 1_000).expect("observation"), + None, + ) + .expect("connect work"); + assert_eq!(work.kind(), MycNip46WorkKind::Connect); + work.connection_admission_request( + MycConnectionPolicyGeneration::new(1).expect("generation"), + MycConnectionNonce::from_injected_entropy([0x44; 32]), + MycConnectionTimeUnixMs::new(RECEIVED_AT_MS + 1_000).expect("observation"), + None, + MycConnectionAdmissionPolicy::ExplicitApproval, + MycRateRelayId::new("primary").expect("relay"), + ) + .expect("admission request"); + + let secret_connect = protocol_request( + Method::Connect, + vec![keys(3).public_key().to_hex(), "forbidden-secret".into()], + ); + let prepared = prepared_request(secret_connect, 21); + let record = prepared.signer_request.admitted_record_for_test(); + assert_eq!( + prepare_myc_nip46_work( + prepared, + record, + None, + configuration().provider_contract(), + MycConnectionTimeUnixMs::new(RECEIVED_AT_MS + 1_000).expect("observation"), + None, + ) + .expect_err("connect secret"), + work_error(MycNip46WorkErrorKind::InvalidBinding) + ); + + let prepared = prepared_request( + protocol_request( + Method::Nip44Encrypt, + vec![keys(4).public_key().to_hex(), "protected-marker".into()], + ), + 22, + ); + let record = prepared.signer_request.admitted_record_for_test(); + let denied = active_connection(&prepared, &[], Some(RECEIVED_AT_MS + 100_000)); + assert_eq!( + prepare_myc_nip46_work( + prepared, + record, + Some(denied), + configuration().provider_contract(), + MycConnectionTimeUnixMs::new(RECEIVED_AT_MS + 1_000).expect("observation"), + Some(MycProviderDeadlineUnixMs::new(PROVIDER_DEADLINE_MS).expect("deadline")), + ) + .expect_err("missing permission"), + work_error(MycNip46WorkErrorKind::PermissionDenied) + ); + + let prepared = prepared_request(Request::Ping, 24); + let record = prepared.signer_request.admitted_record_for_test(); + let expired = active_connection(&prepared, &[], Some(RECEIVED_AT_MS + 500)); + assert_eq!( + prepare_myc_nip46_work( + prepared, + record, + Some(expired), + configuration().provider_contract(), + MycConnectionTimeUnixMs::new(RECEIVED_AT_MS + 1_000).expect("observation"), + None, + ) + .expect_err("expired connection"), + work_error(MycNip46WorkErrorKind::ConnectionExpired) + ); + + let prepared = prepared_request( + protocol_request( + Method::Nip04Encrypt, + vec![keys(4).public_key().to_hex(), "bounded".into()], + ), + 25, + ); + let record = prepared.signer_request.admitted_record_for_test(); + let connection = active_connection( + &prepared, + &[MycConnectionPermission::Nip04Encrypt], + Some(RECEIVED_AT_MS + 100_000), + ); + assert_eq!( + prepare_myc_nip46_work( + prepared, + record, + Some(connection), + configuration().provider_contract(), + MycConnectionTimeUnixMs::new(PROVIDER_DEADLINE_MS).expect("observation"), + Some(MycProviderDeadlineUnixMs::new(PROVIDER_DEADLINE_MS).expect("deadline")), + ) + .expect_err("nonfuture provider deadline"), + work_error(MycNip46WorkErrorKind::ProviderWorkRejected) + ); + + let prepared = prepared_request(Request::SignEvent(unsigned_sign_event_for(4)), 28); + let record = prepared.signer_request.admitted_record_for_test(); + let connection = active_connection( + &prepared, + &[MycConnectionPermission::SignEvent(1)], + Some(RECEIVED_AT_MS + 100_000), + ); + assert_eq!( + prepare_myc_nip46_work( + prepared, + record, + Some(connection), + configuration().provider_contract(), + MycConnectionTimeUnixMs::new(RECEIVED_AT_MS + 1_000).expect("observation"), + Some(MycProviderDeadlineUnixMs::new(PROVIDER_DEADLINE_MS).expect("deadline")), + ) + .expect_err("wrong signing author"), + work_error(MycNip46WorkErrorKind::ProviderWorkRejected) + ); + + let prepared = prepared_request(Request::Ping, 26); + let mismatched = prepared_request(Request::Ping, 27) + .signer_request + .admitted_record_for_test(); + let connection = active_connection(&prepared, &[], Some(RECEIVED_AT_MS + 100_000)); + assert_eq!( + prepare_myc_nip46_work( + prepared, + mismatched, + Some(connection), + configuration().provider_contract(), + MycConnectionTimeUnixMs::new(RECEIVED_AT_MS + 1_000).expect("observation"), + None, + ) + .expect_err("mismatched durable request"), + work_error(MycNip46WorkErrorKind::InvalidBinding) + ); + + let custom = Request::Custom { + method: Method::custom("vendor_method").expect("custom method"), + params: vec!["protected-marker".to_owned()], + }; + assert_eq!( + prepare_myc_nip46_request( + decrypted(&custom, true, OBSERVED_AT_SECONDS + 23), + MycSignerOperationNonce::from_injected_entropy([23; 32]), + MycRequestReceivedAtUnixMs::new(RECEIVED_AT_MS).expect("received"), + ) + .expect_err("custom method"), + work_error(MycNip46WorkErrorKind::UnsupportedMethod) + ); + } + + #[test] + fn errors_and_work_debug_are_source_free_and_redacted() { + for kind in [ + MycNip46WorkErrorKind::InvalidBinding, + MycNip46WorkErrorKind::DecryptionRejected, + MycNip46WorkErrorKind::UnsupportedMethod, + MycNip46WorkErrorKind::PermissionDenied, + MycNip46WorkErrorKind::ConnectionExpired, + MycNip46WorkErrorKind::ProviderWorkRejected, + ] { + let error = work_error(kind); + assert_eq!(error.kind(), kind); + assert!(!error.code().is_empty()); + assert!(!error.to_string().is_empty()); + assert!(error.source().is_none()); + } + + let prepared = prepared_request(Request::Ping, 30); + assert!(!format!("{prepared:?}").contains("request-")); + assert!(!format!("{prepared:?}").contains("protected-marker")); + assert!(MycProviderResponseObservedAtUnixMs::new(PROVIDER_DEADLINE_MS).is_ok()); + } +} diff --git a/src/provider_contract.rs b/src/provider_contract.rs @@ -7,6 +7,7 @@ use std::path::PathBuf; use nostr::PublicKey; use radroots_runtime_paths::ServiceCredentialArtifactName; use serde_json::Value; +use sha2::{Digest, Sha256}; use zeroize::Zeroizing; /// Exact supported signer-provider contract version. @@ -923,6 +924,41 @@ impl MycProviderOperation { pub const fn input(&self) -> &MycProviderOperationInput { &self.input } + + pub(crate) fn binding_digest(&self) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(b"radroots.myc.provider.operation_binding.v1\0"); + hash_framed(&mut hasher, self.role.as_str().as_bytes()); + hash_framed(&mut hasher, self.instance.as_str().as_bytes()); + hash_framed(&mut hasher, self.provider.as_str().as_bytes()); + hasher.update(self.operation_id.as_bytes()); + hasher.update(self.correlation_id.as_bytes()); + hasher.update(self.deadline.get().to_be_bytes()); + hash_framed(&mut hasher, self.expected_identity.as_hex().as_bytes()); + hash_framed(&mut hasher, self.input.capability().as_str().as_bytes()); + hash_framed( + &mut hasher, + self.input + .peer() + .map(MycProviderPublicIdentity::as_hex) + .unwrap_or_default() + .as_bytes(), + ); + hasher.update( + self.input + .nip44_version() + .map(MycProviderNip44Version::as_u8) + .unwrap_or_default() + .to_be_bytes(), + ); + hash_framed(&mut hasher, self.input.bytes().unwrap_or_default()); + hasher.finalize().into() + } +} + +fn hash_framed(hasher: &mut Sha256, value: &[u8]) { + hasher.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes()); + hasher.update(value); } impl fmt::Debug for MycProviderOperation { @@ -1057,8 +1093,12 @@ fn json_bool(document: &Value, pointer: &str) -> Result<bool, MycProviderContrac #[cfg(test)] mod tests { + use crate::{MycConfigProfile, parse_myc_config_v1}; + use super::*; + const CONFIG: &[u8] = include_bytes!("../contracts/services_hardening/config.v1.example.toml"); + #[test] fn capability_sets_are_closed_ordered_and_duplicate_free() { let transport = MycProviderCapabilitySet::for_role(MycProviderRole::Transport); @@ -1183,6 +1223,103 @@ mod tests { } #[test] + fn operation_binding_covers_every_independently_variable_request_field() { + let config = parse_myc_config_v1(CONFIG, MycConfigProfile::RepoLocal).expect("config"); + let user = config + .provider_contract() + .binding(MycProviderRole::User) + .expect("user binding"); + let transport = config + .provider_contract() + .binding(MycProviderRole::Transport) + .expect("transport binding"); + let operation_id = MycProviderOperationId::from_bytes([0x11; 32]); + let correlation_id = MycProviderCorrelationId::from_bytes([0x22; 32]); + let deadline = MycProviderDeadlineUnixMs::new(1_900_000_000_000).expect("deadline"); + let operation = + |binding: &MycProviderBinding, operation_id, correlation_id, deadline, input| { + MycProviderOperation::new(binding, operation_id, correlation_id, deadline, input) + .expect("operation") + }; + let base = operation( + user, + operation_id, + correlation_id, + deadline, + MycProviderOperationInput::public_identity(), + ); + for changed in [ + operation( + user, + MycProviderOperationId::from_bytes([0x12; 32]), + correlation_id, + deadline, + MycProviderOperationInput::public_identity(), + ), + operation( + user, + operation_id, + MycProviderCorrelationId::from_bytes([0x23; 32]), + deadline, + MycProviderOperationInput::public_identity(), + ), + operation( + user, + operation_id, + correlation_id, + MycProviderDeadlineUnixMs::new(deadline.get() + 1).expect("changed deadline"), + MycProviderOperationInput::public_identity(), + ), + operation( + user, + operation_id, + correlation_id, + deadline, + MycProviderOperationInput::sign_event(b"{}").expect("sign event"), + ), + operation( + transport, + operation_id, + correlation_id, + deadline, + MycProviderOperationInput::public_identity(), + ), + ] { + assert_ne!(base.binding_digest(), changed.binding_digest()); + } + + let first_peer = MycProviderPublicIdentity::new(&"4".repeat(64)).expect("first peer"); + let second_peer = MycProviderPublicIdentity::new( + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .expect("second peer"); + let first = operation( + user, + operation_id, + correlation_id, + deadline, + MycProviderOperationInput::nip04_encrypt(first_peer.clone(), b"first") + .expect("first input"), + ); + let changed_bytes = operation( + user, + operation_id, + correlation_id, + deadline, + MycProviderOperationInput::nip04_encrypt(first_peer, b"second").expect("changed bytes"), + ); + let changed_peer = operation( + user, + operation_id, + correlation_id, + deadline, + MycProviderOperationInput::nip04_encrypt(second_peer, b"first").expect("changed peer"), + ); + assert_ne!(first.binding_digest(), changed_bytes.binding_digest()); + assert_ne!(first.binding_digest(), changed_peer.binding_digest()); + } + + #[test] fn every_public_error_is_fixed_and_source_free() { for kind in [ MycProviderContractErrorKind::InvalidConfiguration, diff --git a/src/provider_verification.rs b/src/provider_verification.rs @@ -165,6 +165,7 @@ pub struct MycVerifiedProviderResponse { instance: MycProviderInstanceId, role: MycProviderRole, capability: MycProviderCapability, + operation_binding: [u8; 32], result: VerifiedProviderResult, } @@ -199,6 +200,10 @@ impl MycVerifiedProviderResponse { self.capability } + pub(crate) fn matches_operation(&self, operation: &MycProviderOperation) -> bool { + self.operation_binding == operation.binding_digest() + } + /// Returns a verified public identity for describe/public-identity results. #[must_use] pub const fn public_identity(&self) -> Option<&MycProviderPublicIdentity> { @@ -353,6 +358,42 @@ fn verify_response( instance: operation.instance(), role: operation.role(), capability: operation.input().capability(), + operation_binding: operation.binding_digest(), + result, + }) +} + +#[cfg(test)] +pub(crate) fn verify_protected_response_for_test( + binding: &MycProviderBinding, + operation: &MycProviderOperation, + payload: &[u8], +) -> Result<MycVerifiedProviderResponse, MycProviderVerificationError> { + if operation.role() != binding.role() + || operation.instance() != binding.instance() + || operation.provider() != binding.kind() + || operation.expected_identity() != binding.expected_identity() + { + return Err(verification_error( + MycProviderVerificationErrorKind::InvalidBinding, + )); + } + if payload.len() > MYC_PROVIDER_OUTPUT_MAX_BYTES { + return Err(verification_error(MycProviderVerificationErrorKind::Size)); + } + let payload = Zeroizing::new(payload.to_vec()); + let result = match operation.input().capability() { + MycProviderCapability::Nip04Decrypt => verify_nip04_decrypt_result(operation, payload)?, + MycProviderCapability::Nip44Decrypt => verify_nip44_decrypt_result(operation, payload)?, + _ => return Err(result_shape()), + }; + Ok(MycVerifiedProviderResponse { + operation_id: operation.operation_id(), + correlation_id: operation.correlation_id(), + instance: operation.instance(), + role: operation.role(), + capability: operation.input().capability(), + operation_binding: operation.binding_digest(), result, }) } @@ -408,16 +449,7 @@ fn verify_result( 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, - }) + verify_nip04_decrypt_result(operation, decode_payload_allow_empty(payload_hex)?) } ( MycProviderCapability::Nip44Encrypt, @@ -453,23 +485,43 @@ fn verify_result( 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), - }) + verify_nip44_decrypt_result(operation, decode_payload(payload_hex)?) } _ => Err(result_shape()), } } +fn verify_nip04_decrypt_result( + operation: &MycProviderOperation, + payload: Zeroizing<Vec<u8>>, +) -> Result<VerifiedProviderResult, MycProviderVerificationError> { + let input = operation.input().bytes().ok_or_else(result_shape)?; + let ciphertext_length = verify_nip04_ciphertext(input, None)?; + if payload.len() >= ciphertext_length { + return Err(verification_error(MycProviderVerificationErrorKind::Nip04)); + } + Ok(VerifiedProviderResult::Protected { + payload, + nip44_version: None, + }) +} + +fn verify_nip44_decrypt_result( + operation: &MycProviderOperation, + payload: Zeroizing<Vec<u8>>, +) -> Result<VerifiedProviderResult, MycProviderVerificationError> { + let requested_version = operation.input().nip44_version().ok_or_else(result_shape)?; + let input = operation.input().bytes().ok_or_else(result_shape)?; + let padded_length = verify_nip44_ciphertext(input, None, requested_version)?; + 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), + }) +} + fn verify_describe( binding: &MycProviderBinding, public_identity: String, diff --git a/src/state_connection.rs b/src/state_connection.rs @@ -337,6 +337,10 @@ impl MycConnectionPermissionSet { .iter() .all(|permission| other.permissions.binary_search(permission).is_ok()) } + + pub(crate) fn contains(&self, permission: MycConnectionPermission) -> bool { + self.permissions.binary_search(&permission).is_ok() + } } impl fmt::Debug for MycConnectionPermissionSet { @@ -689,6 +693,25 @@ impl MycConnectionRecord { pub const fn authorized_until(&self) -> Option<MycConnectionTimeUnixMs> { self.authorized_until } + + #[cfg(test)] + pub(crate) fn active_for_test( + client_public_key: MycNip46ClientPublicKey, + granted_permissions: MycConnectionPermissionSet, + authorized_until: Option<MycConnectionTimeUnixMs>, + ) -> Self { + Self { + id: MycConnectionId([0x45; 32]), + client_public_key, + requested_permissions: granted_permissions.clone(), + granted_permissions, + policy_generation: MycConnectionPolicyGeneration(1), + status: MycConnectionStatus::Active, + created_at: MycConnectionTimeUnixMs(1), + updated_at: MycConnectionTimeUnixMs(1), + authorized_until, + } + } } impl fmt::Debug for MycConnectionRecord { diff --git a/src/state_request.rs b/src/state_request.rs @@ -432,6 +432,61 @@ impl MycSignerRequest { received_at: self.received_at, } } + + pub(crate) const fn client_public_key(&self) -> &MycNip46ClientPublicKey { + &self.client_public_key + } + + pub(crate) const fn request_id(&self) -> &MycNip46RequestId { + &self.request_id + } + + pub(crate) const fn event_id(&self) -> MycNip46EventId { + self.event_id + } + + pub(crate) const fn method(&self) -> MycSignerRequestMethod { + self.method + } + + pub(crate) const fn request_digest(&self) -> MycSignerRequestDigest { + self.request_digest + } + + pub(crate) const fn received_at(&self) -> MycRequestReceivedAtUnixMs { + self.received_at + } + + fn derived_operation_id(&self) -> MycSignerOperationId { + MycSignerOperationId(derive_operation_id( + &self.request_identity, + &self.operation_nonce, + )) + } + + fn derived_correlation_id(&self) -> MycSignerCorrelationId { + MycSignerCorrelationId(derive_digest( + CORRELATION_ID_DOMAIN, + self.derived_operation_id().as_bytes(), + )) + } + + #[cfg(test)] + pub(crate) fn admitted_record_for_test(&self) -> MycSignerRequestRecord { + MycSignerRequestRecord { + operation_id: self.derived_operation_id(), + correlation_id: self.derived_correlation_id(), + client_public_key: self.client_public_key.clone(), + request_id: self.request_id.clone(), + first_event_id: self.event_id, + method: self.method, + request_digest: self.request_digest, + received_at: self.received_at, + replay_count: 0, + conflict_count: 0, + last_seen_at: self.received_at, + } + } } impl fmt::Debug for MycSignerRequest { @@ -486,6 +541,21 @@ impl MycSignerRequestRecord { pub const fn conflict_count(&self) -> u64 { self.conflict_count } + + pub(crate) fn matches_request(&self, request: &MycSignerRequest) -> bool { + self.operation_id == request.derived_operation_id() + && self.correlation_id == request.derived_correlation_id() + && &self.client_public_key == request.client_public_key() + && &self.request_id == request.request_id() + && self.first_event_id == request.event_id() + && self.method == request.method() + && self.request_digest == request.request_digest() + && self.received_at == request.received_at() + } + + pub(crate) const fn received_at(&self) -> MycRequestReceivedAtUnixMs { + self.received_at + } } impl fmt::Debug for MycSignerRequestRecord { diff --git a/tests/package_boundary.rs b/tests/package_boundary.rs @@ -8,12 +8,15 @@ const PUBLIC_API: &str = include_str!("../contracts/api_baselines/myc.txt"); const NIP46_VERIFICATION: &str = include_str!("../src/nip46_verification.rs"); const NIP46_AUTHORIZATION: &str = include_str!("../src/nip46_authorization.rs"); const NIP46_REPLAY: &str = include_str!("../src/nip46_replay.rs"); +const NIP46_WORK: &str = include_str!("../src/nip46_work.rs"); const NIP46_VERIFICATION_CONTRACT: &str = include_str!("../contracts/services_hardening/nip46_verification.v1.json"); const NIP46_REPLAY_CONTRACT: &str = include_str!("../contracts/services_hardening/nip46_replay.v1.json"); const NIP46_AUTHORIZATION_CONTRACT: &str = include_str!("../contracts/services_hardening/nip46_authorization.v1.json"); +const NIP46_WORK_CONTRACT: &str = + include_str!("../contracts/services_hardening/nip46_work.v1.json"); const SOURCES: &[&str] = &[ include_str!("../src/cli_v1.rs"), include_str!("../src/config_v1.rs"), @@ -21,6 +24,7 @@ const SOURCES: &[&str] = &[ include_str!("../src/nip46_authorization.rs"), include_str!("../src/nip46_replay.rs"), include_str!("../src/nip46_verification.rs"), + include_str!("../src/nip46_work.rs"), include_str!("../src/provider_contract.rs"), include_str!("../src/provider_credential.rs"), include_str!("../src/provider_envelope.rs"), @@ -54,6 +58,7 @@ fn implementation_modules_are_private_and_rustdoc_uses_the_reviewed_readme() { "nip46_authorization", "nip46_replay", "nip46_verification", + "nip46_work", "provider_contract", "provider_credential", "provider_envelope", @@ -107,11 +112,21 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() { "pub struct myc::MycReplayBoundNip46Request", "pub enum myc::MycNip46ReplayDisposition", "pub enum myc::MycNip46VerificationErrorKind", + "pub struct myc::MycNip46DecryptWork", + "pub struct myc::MycDecryptedNip46Request", + "pub struct myc::MycPreparedNip46Request", + "pub struct myc::MycNip46Work", + "pub enum myc::MycNip46WorkKind", + "pub enum myc::MycNip46WorkErrorKind", + "pub struct myc::MycNip46WorkError", "pub struct myc::MycStateHost", "pub struct myc::MycStateRepository", "pub fn myc::admit_myc_nip46_event", "pub fn myc::admit_myc_nip46_request", "pub fn myc::bind_myc_nip46_replay", + "pub fn myc::prepare_myc_nip46_decrypt_work", + "pub fn myc::prepare_myc_nip46_request", + "pub fn myc::prepare_myc_nip46_work", "pub fn myc::verify_myc_nip46_event", "pub fn myc::verify_myc_nip46_request", "pub async fn myc::open_myc_runtime_foundation", @@ -129,6 +144,7 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() { "nip46_authorization", "nip46_replay", "nip46_verification", + "nip46_work", "provider_contract", "provider_credential", "provider_envelope", @@ -179,6 +195,44 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() { } #[test] +fn step145_work_is_exactly_bound_and_transaction_free() { + for forbidden in [ + "sqlx::", + "ServiceSqlite", + "StateRepository", + "StateHost", + "ServiceSqliteTransaction", + "tokio::spawn", + "std::time::SystemTime", + "rand::", + "getrandom", + "RelayPool", + ] { + assert!( + !NIP46_WORK.contains(forbidden), + "Step 145 gained forbidden authority `{forbidden}`" + ); + } + for required in [ + "radroots.myc.nip46.decrypt.operation.v1\\\\0", + "radroots.myc.nip46.decrypt.correlation.v1\\\\0", + "radroots.myc.provider.operation_binding.v1\\\\0", + "radroots_nostr_connect::server::required_permission", + "after_durable_request_admission_returns", + "alternate_plaintext_to_work_path", + "custom_methods", + "provider_execution", + "response_commit", + "relay_publication", + ] { + assert!( + NIP46_WORK_CONTRACT.contains(required), + "Step 145 contract is missing `{required}`" + ); + } +} + +#[test] fn step144_authorization_is_configuration_bound_and_reuses_durable_state() { for forbidden in [ "sqlx::", @@ -265,7 +319,7 @@ fn public_errors_remain_crate_owned_redacted_and_source_free() { .lines() .filter(|line| line.starts_with("pub struct myc::") && line.ends_with("Error")) .count(); - assert_eq!(public_error_count, 21); + assert_eq!(public_error_count, 22); assert!(!PUBLIC_API.contains("pub struct myc::MycRuntimeFoundation {")); assert!(!PUBLIC_API.contains("pub struct myc::MycStateHost {")); } diff --git a/tests/services_hardening_nip46_work.rs b/tests/services_hardening_nip46_work.rs @@ -0,0 +1,152 @@ +#![forbid(unsafe_code)] + +use serde_json::{Value, json}; + +const CONTRACT: &str = include_str!("../contracts/services_hardening/nip46_work.v1.json"); +const SOURCE: &str = include_str!("../src/nip46_work.rs"); + +#[test] +fn machine_contract_freezes_decryption_method_and_work_authority() { + let contract: Value = serde_json::from_str(CONTRACT).expect("work contract"); + assert_eq!(contract["schema"], "radroots.myc.nip46-work.v1"); + assert_eq!(contract["contract_version"], 1); + assert_eq!( + contract["decryption"]["operation_id_domain"], + "radroots.myc.nip46.decrypt.operation.v1\\0" + ); + assert_eq!( + contract["decryption"]["correlation_id_domain"], + "radroots.myc.nip46.decrypt.correlation.v1\\0" + ); + assert_eq!( + contract["decryption"]["provider_result_binding"]["domain"], + "radroots.myc.provider.operation_binding.v1\\0" + ); + assert_eq!( + contract["decryption"]["provider_result_binding"]["field_order"], + json!([ + "role", + "instance", + "provider", + "operation_id", + "correlation_id", + "absolute_deadline_unix_ms", + "expected_identity", + "capability", + "peer_or_empty", + "nip44_version_or_zero", + "input_or_empty" + ]) + ); + assert_eq!( + contract["method_inventory"], + json!([ + "connect", + "get_public_key", + "get_session_capability", + "sign_event", + "nip04_encrypt", + "nip04_decrypt", + "nip44_encrypt", + "nip44_decrypt", + "ping", + "switch_relays", + "logout" + ]) + ); + assert_eq!(contract["custom_methods"], "unsupported_v1"); + assert_eq!(contract["work_classes"]["provider"]["role"], "user"); + assert_eq!( + contract["work_classes"]["provider"]["sign_event_input"], + json!([ + "canonical_unsigned_json", + "valid_optional_event_id", + "exact_configured_user_author" + ]) + ); + assert_eq!( + contract["authorization"]["required_permission_api"], + "radroots_nostr_connect::server::required_permission" + ); + assert_eq!(contract["sqlite"]["transaction_parameter"], "forbidden"); + assert_eq!( + contract["nonclaims"], + json!([ + "provider_execution", + "response_commit", + "outbox_creation", + "relay_publication" + ]) + ); +} + +#[test] +fn implementation_uses_the_shared_permission_truth_and_has_no_external_authority() { + for required in [ + "server::required_permission", + "required_permission(&request)", + "record.matches_request(&signer_request)", + "response.matches_operation(&self.operation)", + "MycProviderRole::Transport", + "MycProviderRole::User", + "Request::Custom { .. }", + "MycNip46WorkErrorKind::UnsupportedMethod", + ] { + assert!( + SOURCE.contains(required), + "missing Step 145 binding `{required}`" + ); + } + + for forbidden in [ + "sqlx::", + "ServiceSqlite", + "StateRepository", + "StateHost", + "ServiceSqliteTransaction", + "tokio::spawn", + "std::time::SystemTime", + "Timestamp::now", + "rand::", + "getrandom", + "RelayPool", + ".execute(", + ".publish(", + ] { + assert!( + !SOURCE.contains(forbidden), + "Step 145 work gained forbidden authority `{forbidden}`" + ); + } +} + +#[test] +fn public_work_values_are_sealed_and_redacted() { + for required in [ + "pub struct MycNip46DecryptWork {", + "pub struct MycDecryptedNip46Request {", + "pub struct MycPreparedNip46Request {", + "pub struct MycNip46Work {", + "MycDecryptedNip46Request([redacted])", + ".field(\"request\", &\"[redacted]\")", + "impl Error for MycNip46WorkError {}", + ] { + assert!( + SOURCE.contains(required), + "missing sealed boundary `{required}`" + ); + } + for forbidden in [ + "pub replay:", + "pub signer_request:", + "pub request:", + "pub payload:", + "fn source(", + "impl Serialize for MycNip46Work", + ] { + assert!( + !SOURCE.contains(forbidden), + "public work leak `{forbidden}`" + ); + } +}