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:
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}`"
+ );
+ }
+}