myc

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

commit 151cf783ffc7b0a287bd14824279bdde00c230aa
parent 9057f26564a68ecd2bb127d5fd4fdec41f6b8ffa
Author: triesap <tyson@radroots.org>
Date:   Sat, 22 Aug 2026 00:16:24 +0000

feat(myc): verify NIP-46 input

- verify signed event identity, receiver, time, and envelope context
- decode bounded plaintext through the typed NIP-46 request model
- freeze the sealed API, machine contract, and negative boundary tests

Diffstat:
Mcontracts/api_baselines/myc.txt | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acontracts/services_hardening/nip46_verification.v1.json | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 6++++++
Msrc/nip46_admission.rs | 8++++++++
Asrc/nip46_verification.rs | 561+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/package_boundary.rs | 46+++++++++++++++++++++++++++++++++++++++++++++-
Atests/services_hardening_nip46_verification.rs | 428+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 1154 insertions(+), 1 deletion(-)

diff --git a/contracts/api_baselines/myc.txt b/contracts/api_baselines/myc.txt @@ -299,6 +299,24 @@ pub myc::MycNip46AdmissionErrorKind::TagsTooLarge pub myc::MycNip46AdmissionErrorKind::TooManyRequestParameters pub myc::MycNip46AdmissionErrorKind::TooManyTagElements pub myc::MycNip46AdmissionErrorKind::TooManyTags +pub enum myc::MycNip46EncryptionContext +pub myc::MycNip46EncryptionContext::Nip04 +pub myc::MycNip46EncryptionContext::Nip44V2 +pub enum myc::MycNip46VerificationErrorKind +pub myc::MycNip46VerificationErrorKind::AuthoredTimeRejected +pub myc::MycNip46VerificationErrorKind::InvalidEventId +pub myc::MycNip46VerificationErrorKind::InvalidKind +pub myc::MycNip46VerificationErrorKind::InvalidNip04Envelope +pub myc::MycNip46VerificationErrorKind::InvalidNip44Envelope +pub myc::MycNip46VerificationErrorKind::InvalidObservationTime +pub myc::MycNip46VerificationErrorKind::InvalidReceiver +pub myc::MycNip46VerificationErrorKind::InvalidSender +pub myc::MycNip46VerificationErrorKind::InvalidSignature +pub myc::MycNip46VerificationErrorKind::InvalidTimePolicy +pub myc::MycNip46VerificationErrorKind::InvalidTransportBinding +pub myc::MycNip46VerificationErrorKind::InvalidTypedRequest +pub myc::MycNip46VerificationErrorKind::MalformedEvent +pub myc::MycNip46VerificationErrorKind::NonCanonicalEvent pub enum myc::MycProviderCapability pub myc::MycProviderCapability::Describe pub myc::MycProviderCapability::Nip04Decrypt @@ -908,6 +926,11 @@ pub const fn myc::MycNip46AdmissionLimits::event_wire_bytes(self) -> usize pub fn myc::MycNip46AdmissionLimits::from_config(&myc::MycConfigDocumentV1) -> core::result::Result<Self, myc::MycNip46AdmissionError> impl core::fmt::Debug for myc::MycNip46AdmissionLimits pub fn myc::MycNip46AdmissionLimits::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycNip46AuthoredTimePolicy +impl myc::MycNip46AuthoredTimePolicy +pub const fn myc::MycNip46AuthoredTimePolicy::maximum_future_seconds(self) -> u64 +pub const fn myc::MycNip46AuthoredTimePolicy::maximum_past_seconds(self) -> u64 +pub fn myc::MycNip46AuthoredTimePolicy::new(u64, u64) -> core::result::Result<Self, myc::MycNip46VerificationError> pub struct myc::MycNip46ClientPublicKey(_) impl myc::MycNip46ClientPublicKey pub fn myc::MycNip46ClientPublicKey::as_hex(&self) -> &str @@ -921,12 +944,24 @@ impl myc::MycNip46EventId pub const fn myc::MycNip46EventId::from_bytes([u8; 32]) -> Self impl core::fmt::Debug for myc::MycNip46EventId pub fn myc::MycNip46EventId::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycNip46ObservedAtUnixSeconds(_) +impl myc::MycNip46ObservedAtUnixSeconds +pub const fn myc::MycNip46ObservedAtUnixSeconds::get(self) -> u64 +pub fn myc::MycNip46ObservedAtUnixSeconds::new(u64) -> core::result::Result<Self, myc::MycNip46VerificationError> pub struct myc::MycNip46RequestId(_) impl myc::MycNip46RequestId pub fn myc::MycNip46RequestId::as_str(&self) -> &str pub fn myc::MycNip46RequestId::new(&str) -> core::result::Result<Self, myc::MycSignerRequestError> impl core::fmt::Debug for myc::MycNip46RequestId pub fn myc::MycNip46RequestId::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycNip46VerificationError +impl myc::MycNip46VerificationError +pub const fn myc::MycNip46VerificationError::kind(self) -> myc::MycNip46VerificationErrorKind +impl core::error::Error for myc::MycNip46VerificationError +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::MycNormalizedConfigDigest(_) impl myc::MycNormalizedConfigDigest pub const fn myc::MycNormalizedConfigDigest::as_bytes(&self) -> &[u8; 32] @@ -1247,6 +1282,24 @@ pub fn myc::MycUntrustedProviderOutput::as_bytes(&self) -> &[u8] pub fn myc::MycUntrustedProviderOutput::new(&[u8]) -> core::result::Result<Self, myc::MycProviderContractError> impl core::fmt::Debug for myc::MycUntrustedProviderOutput pub fn myc::MycUntrustedProviderOutput::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycVerifiedNip46Event +impl myc::MycVerifiedNip46Event +pub const fn myc::MycVerifiedNip46Event::authored_at_unix_seconds(&self) -> u64 +pub const fn myc::MycVerifiedNip46Event::client_public_key(&self) -> &myc::MycNip46ClientPublicKey +pub fn myc::MycVerifiedNip46Event::encrypted_content(&self) -> &str +pub const fn myc::MycVerifiedNip46Event::encryption_context(&self) -> myc::MycNip46EncryptionContext +pub const fn myc::MycVerifiedNip46Event::event_id(&self) -> myc::MycNip46EventId +pub fn myc::MycVerifiedNip46Event::original_bytes(&self) -> &[u8] +impl core::fmt::Debug for myc::MycVerifiedNip46Event +pub fn myc::MycVerifiedNip46Event::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycVerifiedNip46Request +impl myc::MycVerifiedNip46Request +pub fn myc::MycVerifiedNip46Request::method(&self) -> &str +pub const fn myc::MycVerifiedNip46Request::parameter_bytes(&self) -> usize +pub const fn myc::MycVerifiedNip46Request::parameter_count(&self) -> usize +pub const fn myc::MycVerifiedNip46Request::request_id(&self) -> &myc::MycNip46RequestId +impl core::fmt::Debug for myc::MycVerifiedNip46Request +pub fn myc::MycVerifiedNip46Request::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct myc::MycVerifiedProviderResponse impl myc::MycVerifiedProviderResponse pub const fn myc::MycVerifiedProviderResponse::capabilities(&self) -> core::option::Option<myc::MycProviderCapabilitySet> @@ -1354,4 +1407,6 @@ pub fn myc::resolve_myc_runtime_context(&radroots_runtime_paths::roots::Radroots pub fn myc::resolve_myc_wrapping_credential(&myc::MycRuntimeContext, &myc::MycProviderBinding) -> core::result::Result<myc::MycWrappingCredential, myc::MycCredentialResolutionError> pub async fn myc::stage_myc_state_restore(&myc::MycRuntimeContext, &myc::MycStateMetadata, myc::MycVerifiedStateBackup) -> core::result::Result<myc::MycStagedStateRestore, myc::MycStateMaintenanceError> pub fn myc::validate_myc_state_catalogs(&radroots_service_sqlite::migration::MigrationCatalog, &radroots_service_sqlite::integrity::catalog::SchemaCatalog) -> core::result::Result<(), myc::MycStateCatalogError> +pub fn myc::verify_myc_nip46_event(myc::MycBoundedNip46Event, &myc::MycProviderBinding, myc::MycNip46ObservedAtUnixSeconds, myc::MycNip46AuthoredTimePolicy) -> core::result::Result<myc::MycVerifiedNip46Event, myc::MycNip46VerificationError> +pub fn myc::verify_myc_nip46_request(myc::MycBoundedNip46Request) -> core::result::Result<myc::MycVerifiedNip46Request, myc::MycNip46VerificationError> pub fn myc::verify_myc_state_backup(&[u8], radroots_service_sqlite::backup::manifest::BackupManifestSha256, &std::path::Path, &myc::MycStateMetadata, core::num::nonzero::NonZeroU64) -> core::result::Result<myc::MycVerifiedStateBackup, myc::MycStateMaintenanceError> diff --git a/contracts/services_hardening/nip46_verification.v1.json b/contracts/services_hardening/nip46_verification.v1.json @@ -0,0 +1,51 @@ +{ + "schema": "radroots.myc.nip46-verification.v1", + "contract_version": 1, + "prerequisite": "nip46_admission.v1", + "event": { + "kind": 24133, + "fields": "exact_closed_step141_shape", + "identifiers": "canonical_lowercase_fixed_width_hex", + "event_id": "recomputed_and_exact", + "signature": "bip340_schnorr_verified", + "sender": "valid_xonly_public_key_distinct_from_receiver", + "receiver_binding": "configured_transport_role_provider_binding_only", + "receiver": "exactly_one_exact_two_element_p_tag_matching_bound_transport_identity", + "other_tags": "forbidden", + "original_bytes": "retained_exactly", + "relay_io": "forbidden" + }, + "authored_time": { + "observation": "positive_injected_unix_seconds", + "policy": "explicit_caller_supplied_inclusive_past_and_future_seconds", + "default": "none", + "arithmetic": "saturating_closed_interval" + }, + "encryption": { + "contexts": ["nip04", "nip44_v2"], + "nip04": "canonical_base64_nonempty_block_ciphertext_plus_exact_16_byte_iv", + "nip44_v2": "canonical_base64_version_2_exact_payload_length_class", + "ciphertext": "retained_bounded_and_redacted", + "decryption": "deferred_to_step_145", + "plaintext_event_binding": "deferred_to_step_145" + }, + "request": { + "input": "step141_bounded_plaintext", + "decoder": "radroots_nostr_connect_request_message", + "shape": "exact_id_method_string_params_string_array", + "identifier": "nonempty_bounded_trimmed_control_free", + "method_and_params": "typed_and_semantically_validated", + "canonical_bytes": "privately_retained_typed_reserialization", + "unsupported_method_rejection": "deferred_to_step_145", + "replay_and_conflicting_reuse": "deferred_to_step_143" + }, + "nonclaims": [ + "decryption", + "plaintext_event_binding", + "replay", + "authorization", + "provider_execution", + "database_mutation", + "relay_publication" + ] +} diff --git a/src/lib.rs b/src/lib.rs @@ -4,6 +4,7 @@ mod cli_v1; mod config_v1; mod nip46_admission; +mod nip46_verification; mod provider_contract; mod provider_credential; mod provider_envelope; @@ -37,6 +38,11 @@ pub use nip46_admission::{ MycNip46AdmissionErrorKind, MycNip46AdmissionLimits, admit_myc_nip46_event, admit_myc_nip46_request, }; +pub use nip46_verification::{ + MycNip46AuthoredTimePolicy, MycNip46EncryptionContext, MycNip46ObservedAtUnixSeconds, + MycNip46VerificationError, MycNip46VerificationErrorKind, MycVerifiedNip46Event, + MycVerifiedNip46Request, verify_myc_nip46_event, verify_myc_nip46_request, +}; 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_admission.rs b/src/nip46_admission.rs @@ -262,6 +262,10 @@ impl MycBoundedNip46Event { pub const fn tag_bytes(&self) -> usize { self.tag_bytes } + + pub(crate) fn into_verification_parts(self) -> (Box<[u8]>, Box<str>) { + (self.original, self.encrypted_content) + } } impl fmt::Debug for MycBoundedNip46Event { @@ -316,6 +320,10 @@ impl MycBoundedNip46Request { pub const fn parameter_bytes(&self) -> usize { self.parameter_bytes } + + pub(crate) fn into_verification_parts(self) -> (Box<[u8]>, usize, usize) { + (self.plaintext, self.parameter_count, self.parameter_bytes) + } } impl fmt::Debug for MycBoundedNip46Request { diff --git a/src/nip46_verification.rs b/src/nip46_verification.rs @@ -0,0 +1,561 @@ +//! Cryptographic and typed verification for structurally admitted NIP-46 input. + +use core::fmt; +use std::error::Error; + +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use nostr::{Event, Kind}; +use radroots_nostr_connect::{ + message::{RPC_KIND, RequestMessage}, + method::METHOD_MAX_BYTES, +}; +use serde::Deserialize; + +use crate::{ + MycBoundedNip46Event, MycBoundedNip46Request, MycNip46ClientPublicKey, MycNip46EventId, + MycNip46RequestId, MycProviderBinding, MycProviderRole, +}; + +const NIP04_IV_BYTES: usize = 16; +const NIP04_BLOCK_BYTES: usize = 16; +const NIP44_VERSION_V2: u8 = 2; +const NIP44_NONCE_BYTES: usize = 32; +const NIP44_LENGTH_PREFIX_BYTES: usize = 2; +const NIP44_HMAC_BYTES: usize = 32; +const NIP44_MIN_PADDED_BYTES: usize = 32; +const NIP44_MAX_PADDED_BYTES: usize = 65_536; + +/// Encryption envelope declared by one verified NIP-46 event. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum MycNip46EncryptionContext { + Nip04, + Nip44V2, +} + +/// Injected UTC second at which an inbound event is verified. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct MycNip46ObservedAtUnixSeconds(u64); + +impl MycNip46ObservedAtUnixSeconds { + /// Validates a positive instant representable by SQLite's signed integer. + pub fn new(value: u64) -> Result<Self, MycNip46VerificationError> { + if value == 0 || i64::try_from(value).is_err() { + return Err(verification_error( + MycNip46VerificationErrorKind::InvalidObservationTime, + )); + } + Ok(Self(value)) + } + + /// Returns the injected UTC second. + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } +} + +/// Explicit caller-selected authored-time window with no implicit default. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct MycNip46AuthoredTimePolicy { + maximum_past_seconds: u64, + maximum_future_seconds: u64, +} + +impl MycNip46AuthoredTimePolicy { + /// Validates inclusive past and future tolerances. + pub fn new( + maximum_past_seconds: u64, + maximum_future_seconds: u64, + ) -> Result<Self, MycNip46VerificationError> { + if i64::try_from(maximum_past_seconds).is_err() + || i64::try_from(maximum_future_seconds).is_err() + { + return Err(verification_error( + MycNip46VerificationErrorKind::InvalidTimePolicy, + )); + } + Ok(Self { + maximum_past_seconds, + maximum_future_seconds, + }) + } + + /// Returns the inclusive maximum event age. + #[must_use] + pub const fn maximum_past_seconds(self) -> u64 { + self.maximum_past_seconds + } + + /// Returns the inclusive maximum future skew. + #[must_use] + pub const fn maximum_future_seconds(self) -> u64 { + self.maximum_future_seconds + } +} + +/// Stable source-free classification for NIP-46 verification failures. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycNip46VerificationErrorKind { + InvalidObservationTime, + InvalidTimePolicy, + InvalidTransportBinding, + MalformedEvent, + NonCanonicalEvent, + InvalidKind, + InvalidSender, + InvalidReceiver, + InvalidEventId, + InvalidSignature, + AuthoredTimeRejected, + InvalidNip04Envelope, + InvalidNip44Envelope, + InvalidTypedRequest, +} + +impl MycNip46VerificationErrorKind { + const fn message(self) -> &'static str { + match self { + Self::InvalidObservationTime => "NIP-46 observation time is invalid", + Self::InvalidTimePolicy => "NIP-46 authored-time policy is invalid", + Self::InvalidTransportBinding => "NIP-46 transport binding is invalid", + Self::MalformedEvent => "NIP-46 event cannot be decoded", + Self::NonCanonicalEvent => "NIP-46 event encoding is not canonical", + Self::InvalidKind => "NIP-46 event kind is invalid", + Self::InvalidSender => "NIP-46 event sender is invalid", + Self::InvalidReceiver => "NIP-46 event receiver is invalid", + Self::InvalidEventId => "NIP-46 event identifier is invalid", + Self::InvalidSignature => "NIP-46 event signature is invalid", + Self::AuthoredTimeRejected => "NIP-46 event authored time is outside policy", + Self::InvalidNip04Envelope => "NIP-46 NIP-04 envelope is invalid", + Self::InvalidNip44Envelope => "NIP-46 NIP-44 envelope is invalid", + Self::InvalidTypedRequest => "NIP-46 typed request is invalid", + } + } +} + +/// One redacted NIP-46 verification failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct MycNip46VerificationError { + kind: MycNip46VerificationErrorKind, +} + +impl MycNip46VerificationError { + /// Returns the stable failure classification. + #[must_use] + pub const fn kind(self) -> MycNip46VerificationErrorKind { + self.kind + } +} + +impl fmt::Debug for MycNip46VerificationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycNip46VerificationError") + .field("kind", &self.kind) + .finish() + } +} + +impl fmt::Display for MycNip46VerificationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.kind.message()) + } +} + +impl Error for MycNip46VerificationError {} + +/// One cryptographically verified encrypted NIP-46 event. +pub struct MycVerifiedNip46Event { + original: Box<[u8]>, + encrypted_content: Box<str>, + event_id: MycNip46EventId, + client_public_key: MycNip46ClientPublicKey, + authored_at_unix_seconds: u64, + encryption_context: MycNip46EncryptionContext, +} + +impl MycVerifiedNip46Event { + /// Returns the exact signed event bytes admitted by Step 141. + #[must_use] + pub fn original_bytes(&self) -> &[u8] { + &self.original + } + + /// Returns the verified event identity. + #[must_use] + pub const fn event_id(&self) -> MycNip46EventId { + self.event_id + } + + /// Returns the verified client transport identity. + #[must_use] + pub const fn client_public_key(&self) -> &MycNip46ClientPublicKey { + &self.client_public_key + } + + /// Returns the verified event authored time. + #[must_use] + pub const fn authored_at_unix_seconds(&self) -> u64 { + self.authored_at_unix_seconds + } + + /// Returns the verified encrypted-envelope context. + #[must_use] + pub const fn encryption_context(&self) -> MycNip46EncryptionContext { + self.encryption_context + } + + /// Returns the bounded ciphertext for the later governed decryption boundary. + #[must_use] + pub fn encrypted_content(&self) -> &str { + &self.encrypted_content + } +} + +impl fmt::Debug for MycVerifiedNip46Event { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycVerifiedNip46Event") + .field("wire_bytes", &self.original.len()) + .field("ciphertext_bytes", &self.encrypted_content.len()) + .field("authored_at_unix_seconds", &self.authored_at_unix_seconds) + .field("encryption_context", &self.encryption_context) + .field("identity", &"[redacted]") + .finish() + } +} + +/// One exact typed NIP-46 request decoded from admitted plaintext. +pub struct MycVerifiedNip46Request { + request_id: MycNip46RequestId, + method: Box<str>, + canonical_request: Box<[u8]>, + parameter_count: usize, + parameter_bytes: usize, +} + +impl MycVerifiedNip46Request { + /// Returns the validated request identity. + #[must_use] + pub const fn request_id(&self) -> &MycNip46RequestId { + &self.request_id + } + + /// Returns the canonical typed method spelling. + #[must_use] + pub fn method(&self) -> &str { + &self.method + } + + /// Returns the typed parameter count without exposing parameters. + #[must_use] + pub const fn parameter_count(&self) -> usize { + self.parameter_count + } + + /// Returns the aggregate typed parameter byte count. + #[must_use] + pub const fn parameter_bytes(&self) -> usize { + self.parameter_bytes + } +} + +impl fmt::Debug for MycVerifiedNip46Request { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycVerifiedNip46Request") + .field("method", &"[redacted]") + .field("canonical_bytes", &self.canonical_request.len()) + .field("parameter_count", &self.parameter_count) + .field("parameter_bytes", &self.parameter_bytes) + .field("identity", &"[redacted]") + .finish() + } +} + +/// Verifies one already bounded encrypted NIP-46 event without decrypting it. +pub fn verify_myc_nip46_event( + bounded: MycBoundedNip46Event, + expected_transport: &MycProviderBinding, + observed_at: MycNip46ObservedAtUnixSeconds, + authored_time_policy: MycNip46AuthoredTimePolicy, +) -> Result<MycVerifiedNip46Event, MycNip46VerificationError> { + if expected_transport.role() != MycProviderRole::Transport { + return Err(verification_error( + MycNip46VerificationErrorKind::InvalidTransportBinding, + )); + } + let expected_receiver = expected_transport.expected_identity(); + let (original, encrypted_content) = bounded.into_verification_parts(); + let canonical: CanonicalEventFields<'_> = serde_json::from_slice(&original) + .map_err(|_| verification_error(MycNip46VerificationErrorKind::MalformedEvent))?; + let event: Event = serde_json::from_slice(&original) + .map_err(|_| verification_error(MycNip46VerificationErrorKind::MalformedEvent))?; + + if canonical.id != event.id.to_hex() + || canonical.pubkey != event.pubkey.to_hex() + || canonical.sig != event.sig.to_string() + || canonical.created_at != event.created_at.as_secs() + || canonical.kind != u64::from(event.kind.as_u16()) + || canonical.content != event.content + { + return Err(verification_error( + MycNip46VerificationErrorKind::NonCanonicalEvent, + )); + } + if event.kind != Kind::Custom(RPC_KIND) { + return Err(verification_error( + MycNip46VerificationErrorKind::InvalidKind, + )); + } + let client_public_key = MycNip46ClientPublicKey::new(&event.pubkey.to_hex()) + .map_err(|_| verification_error(MycNip46VerificationErrorKind::InvalidSender))?; + if event.pubkey.to_hex() == expected_receiver.as_hex() { + return Err(verification_error( + MycNip46VerificationErrorKind::InvalidSender, + )); + } + let expected_recipient = ["p", expected_receiver.as_hex()]; + if event.tags.len() != 1 + || event.tags.as_slice()[0] + .as_slice() + .iter() + .map(String::as_str) + .ne(expected_recipient) + { + return Err(verification_error( + MycNip46VerificationErrorKind::InvalidReceiver, + )); + } + if !event.verify_id() { + return Err(verification_error( + MycNip46VerificationErrorKind::InvalidEventId, + )); + } + if !event.verify_signature() { + return Err(verification_error( + MycNip46VerificationErrorKind::InvalidSignature, + )); + } + verify_authored_time( + event.created_at.as_secs(), + observed_at.get(), + authored_time_policy, + )?; + let encryption_context = verify_encryption_context(&encrypted_content)?; + + Ok(MycVerifiedNip46Event { + original, + encrypted_content, + event_id: MycNip46EventId::from_bytes(event.id.to_bytes()), + client_public_key, + authored_at_unix_seconds: event.created_at.as_secs(), + encryption_context, + }) +} + +/// Decodes one already bounded plaintext request through the canonical NIP-46 model. +pub fn verify_myc_nip46_request( + bounded: MycBoundedNip46Request, +) -> Result<MycVerifiedNip46Request, MycNip46VerificationError> { + let (plaintext, parameter_count, parameter_bytes) = bounded.into_verification_parts(); + let message: RequestMessage = serde_json::from_slice(&plaintext) + .map_err(|_| verification_error(MycNip46VerificationErrorKind::InvalidTypedRequest))?; + let request_id = message + .request_id() + .map_err(|_| verification_error(MycNip46VerificationErrorKind::InvalidTypedRequest))?; + let request_id = MycNip46RequestId::new(request_id.as_str()) + .map_err(|_| verification_error(MycNip46VerificationErrorKind::InvalidTypedRequest))?; + let method = message.payload().method().to_string(); + if method.is_empty() || method.len() > METHOD_MAX_BYTES { + return Err(verification_error( + MycNip46VerificationErrorKind::InvalidTypedRequest, + )); + } + let canonical_request = serde_json::to_vec(&message) + .map_err(|_| verification_error(MycNip46VerificationErrorKind::InvalidTypedRequest))?; + + Ok(MycVerifiedNip46Request { + request_id, + method: method.into_boxed_str(), + canonical_request: canonical_request.into_boxed_slice(), + parameter_count, + parameter_bytes, + }) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CanonicalEventFields<'a> { + id: &'a str, + pubkey: &'a str, + created_at: u64, + kind: u64, + #[serde(rename = "tags")] + _tags: serde::de::IgnoredAny, + content: &'a str, + sig: &'a str, +} + +fn verify_authored_time( + authored_at: u64, + observed_at: u64, + policy: MycNip46AuthoredTimePolicy, +) -> Result<(), MycNip46VerificationError> { + let earliest = observed_at.saturating_sub(policy.maximum_past_seconds()); + let latest = observed_at.saturating_add(policy.maximum_future_seconds()); + if !(earliest..=latest).contains(&authored_at) { + return Err(verification_error( + MycNip46VerificationErrorKind::AuthoredTimeRejected, + )); + } + Ok(()) +} + +fn verify_encryption_context( + ciphertext: &str, +) -> Result<MycNip46EncryptionContext, MycNip46VerificationError> { + if ciphertext.contains("?iv=") { + verify_nip04_envelope(ciphertext)?; + Ok(MycNip46EncryptionContext::Nip04) + } else { + verify_nip44_envelope(ciphertext)?; + Ok(MycNip46EncryptionContext::Nip44V2) + } +} + +fn verify_nip04_envelope(ciphertext: &str) -> Result<(), MycNip46VerificationError> { + let (encrypted, iv) = ciphertext + .split_once("?iv=") + .ok_or_else(|| verification_error(MycNip46VerificationErrorKind::InvalidNip04Envelope))?; + if encrypted.is_empty() || iv.is_empty() || iv.contains("?iv=") { + return Err(verification_error( + MycNip46VerificationErrorKind::InvalidNip04Envelope, + )); + } + let encrypted = decode_canonical_base64( + encrypted, + MycNip46VerificationErrorKind::InvalidNip04Envelope, + )?; + let iv = decode_canonical_base64(iv, MycNip46VerificationErrorKind::InvalidNip04Envelope)?; + if encrypted.is_empty() + || !encrypted.len().is_multiple_of(NIP04_BLOCK_BYTES) + || iv.len() != NIP04_IV_BYTES + { + return Err(verification_error( + MycNip46VerificationErrorKind::InvalidNip04Envelope, + )); + } + Ok(()) +} + +fn verify_nip44_envelope(ciphertext: &str) -> Result<(), MycNip46VerificationError> { + let decoded = decode_canonical_base64( + ciphertext, + MycNip46VerificationErrorKind::InvalidNip44Envelope, + )?; + if decoded.first() != Some(&NIP44_VERSION_V2) { + return Err(verification_error( + MycNip46VerificationErrorKind::InvalidNip44Envelope, + )); + } + let overhead = 1usize + .checked_add(NIP44_NONCE_BYTES) + .and_then(|value| value.checked_add(NIP44_LENGTH_PREFIX_BYTES)) + .and_then(|value| value.checked_add(NIP44_HMAC_BYTES)) + .expect("fixed NIP-44 overhead fits usize"); + let padded = decoded + .len() + .checked_sub(overhead) + .ok_or_else(|| verification_error(MycNip46VerificationErrorKind::InvalidNip44Envelope))?; + if !is_valid_nip44_padding_length(padded) { + return Err(verification_error( + MycNip46VerificationErrorKind::InvalidNip44Envelope, + )); + } + Ok(()) +} + +fn decode_canonical_base64( + encoded: &str, + kind: MycNip46VerificationErrorKind, +) -> Result<Vec<u8>, MycNip46VerificationError> { + let decoded = BASE64_STANDARD + .decode(encoded) + .map_err(|_| verification_error(kind))?; + if BASE64_STANDARD.encode(&decoded) != encoded { + return Err(verification_error(kind)); + } + Ok(decoded) +} + +fn is_valid_nip44_padding_length(length: usize) -> bool { + if !(NIP44_MIN_PADDED_BYTES..=NIP44_MAX_PADDED_BYTES).contains(&length) { + return false; + } + if length <= 256 { + return length.is_power_of_two(); + } + let exponent = usize::BITS - 1 - length.leading_zeros(); + let chunk = 1usize << (exponent - 3); + length.is_multiple_of(chunk) +} + +const fn verification_error(kind: MycNip46VerificationErrorKind) -> MycNip46VerificationError { + MycNip46VerificationError { kind } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn authored_time_boundaries_are_inclusive_and_checked() { + let policy = MycNip46AuthoredTimePolicy::new(10, 2).expect("policy"); + assert!(verify_authored_time(90, 100, policy).is_ok()); + assert!(verify_authored_time(102, 100, policy).is_ok()); + assert_eq!( + verify_authored_time(89, 100, policy).unwrap_err().kind(), + MycNip46VerificationErrorKind::AuthoredTimeRejected + ); + assert_eq!( + verify_authored_time(103, 100, policy).unwrap_err().kind(), + MycNip46VerificationErrorKind::AuthoredTimeRejected + ); + } + + #[test] + fn every_protocol_padding_boundary_is_closed() { + for valid in [32, 64, 128, 256, 288, 320, 65_536] { + assert!(is_valid_nip44_padding_length(valid)); + } + for invalid in [0, 31, 33, 257, 287, 65_535, 65_537] { + assert!(!is_valid_nip44_padding_length(invalid)); + } + } + + #[test] + fn every_error_is_fixed_and_source_free() { + for kind in [ + MycNip46VerificationErrorKind::InvalidObservationTime, + MycNip46VerificationErrorKind::InvalidTimePolicy, + MycNip46VerificationErrorKind::InvalidTransportBinding, + MycNip46VerificationErrorKind::MalformedEvent, + MycNip46VerificationErrorKind::NonCanonicalEvent, + MycNip46VerificationErrorKind::InvalidKind, + MycNip46VerificationErrorKind::InvalidSender, + MycNip46VerificationErrorKind::InvalidReceiver, + MycNip46VerificationErrorKind::InvalidEventId, + MycNip46VerificationErrorKind::InvalidSignature, + MycNip46VerificationErrorKind::AuthoredTimeRejected, + MycNip46VerificationErrorKind::InvalidNip04Envelope, + MycNip46VerificationErrorKind::InvalidNip44Envelope, + MycNip46VerificationErrorKind::InvalidTypedRequest, + ] { + let error = verification_error(kind); + assert_eq!(error.kind(), kind); + assert!(!error.to_string().is_empty()); + assert!(error.source().is_none()); + } + } +} diff --git a/tests/package_boundary.rs b/tests/package_boundary.rs @@ -5,10 +5,14 @@ use std::collections::BTreeSet; const ROOT: &str = include_str!("../src/lib.rs"); const README: &str = include_str!("../README"); const PUBLIC_API: &str = include_str!("../contracts/api_baselines/myc.txt"); +const NIP46_VERIFICATION: &str = include_str!("../src/nip46_verification.rs"); +const NIP46_VERIFICATION_CONTRACT: &str = + include_str!("../contracts/services_hardening/nip46_verification.v1.json"); const SOURCES: &[&str] = &[ include_str!("../src/cli_v1.rs"), include_str!("../src/config_v1.rs"), include_str!("../src/nip46_admission.rs"), + include_str!("../src/nip46_verification.rs"), include_str!("../src/provider_contract.rs"), include_str!("../src/provider_credential.rs"), include_str!("../src/provider_envelope.rs"), @@ -39,6 +43,7 @@ fn implementation_modules_are_private_and_rustdoc_uses_the_reviewed_readme() { "cli_v1", "config_v1", "nip46_admission", + "nip46_verification", "provider_contract", "provider_credential", "provider_envelope", @@ -83,10 +88,16 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() { "pub struct myc::MycBoundedNip46Request", "pub struct myc::MycNip46AdmissionLimits", "pub enum myc::MycNip46AdmissionErrorKind", + "pub struct myc::MycVerifiedNip46Event", + "pub struct myc::MycVerifiedNip46Request", + "pub struct myc::MycNip46AuthoredTimePolicy", + "pub enum myc::MycNip46VerificationErrorKind", "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::verify_myc_nip46_event", + "pub fn myc::verify_myc_nip46_request", "pub async fn myc::open_myc_runtime_foundation", ] { assert!( @@ -99,6 +110,7 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() { "cli_v1", "config_v1", "nip46_admission", + "nip46_verification", "provider_contract", "provider_credential", "provider_envelope", @@ -170,7 +182,39 @@ 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, 20); + assert_eq!(public_error_count, 21); assert!(!PUBLIC_API.contains("pub struct myc::MycRuntimeFoundation {")); assert!(!PUBLIC_API.contains("pub struct myc::MycStateHost {")); } + +#[test] +fn step142_verification_remains_pure_and_defers_later_authority() { + for forbidden in [ + "nip04::decrypt", + "nip44::decrypt", + "ServiceSqlite", + "sqlx::", + "tokio::spawn", + "std::time::SystemTime", + "Timestamp::now", + "RelayPool", + ] { + assert!( + !NIP46_VERIFICATION.contains(forbidden), + "Step 142 gained forbidden authority `{forbidden}`" + ); + } + for required in [ + "\"decryption\": \"deferred_to_step_145\"", + "\"plaintext_event_binding\": \"deferred_to_step_145\"", + "\"replay_and_conflicting_reuse\": \"deferred_to_step_143\"", + "\"authorization\"", + "\"database_mutation\"", + "\"relay_publication\"", + ] { + assert!( + NIP46_VERIFICATION_CONTRACT.contains(required), + "Step 142 contract is missing `{required}`" + ); + } +} diff --git a/tests/services_hardening_nip46_verification.rs b/tests/services_hardening_nip46_verification.rs @@ -0,0 +1,428 @@ +#![forbid(unsafe_code)] + +use std::error::Error; + +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use myc::{ + MycConfigProfile, MycNip46AdmissionLimits, MycNip46AuthoredTimePolicy, + MycNip46EncryptionContext, MycNip46ObservedAtUnixSeconds, MycNip46VerificationErrorKind, + MycProviderBinding, MycProviderRole, admit_myc_nip46_event, admit_myc_nip46_request, + parse_myc_config_v1, verify_myc_nip46_event, verify_myc_nip46_request, +}; +use nostr::{ + Event, EventBuilder, EventId, Keys, Kind, Tag, Timestamp, + nips::{nip04, nip44}, +}; +use serde_json::{Value, json}; + +const CONFIG: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); +const CONTRACT: &str = include_str!("../contracts/services_hardening/nip46_verification.v1.json"); +const OBSERVED_AT: u64 = 1_725_000_000; + +fn limits() -> MycNip46AdmissionLimits { + let config = + parse_myc_config_v1(CONFIG.as_bytes(), MycConfigProfile::RepoLocal).expect("test config"); + MycNip46AdmissionLimits::from_config(&config).expect("limits") +} + +fn keys(seed: u8) -> Keys { + Keys::parse(&format!("{seed:02x}{}", "00".repeat(31))).expect("test keys") +} + +fn provider_binding(role: MycProviderRole, keys: &Keys) -> MycProviderBinding { + let (section, original) = match role { + MycProviderRole::Transport => ( + "[identity.transport]", + "4444444444444444444444444444444444444444444444444444444444444444", + ), + MycProviderRole::User => ( + "[identity.user]", + "2222222222222222222222222222222222222222222222222222222222222222", + ), + MycProviderRole::Discovery => ( + "[identity.discovery.binding]", + "3333333333333333333333333333333333333333333333333333333333333333", + ), + }; + let section_start = CONFIG.find(section).expect("identity section"); + let mut source = CONFIG.to_owned(); + let relative = source[section_start..] + .find(original) + .expect("configured identity"); + let identity_start = section_start + relative; + source.replace_range( + identity_start..identity_start + original.len(), + &keys.public_key().to_hex(), + ); + parse_myc_config_v1(source.as_bytes(), MycConfigProfile::RepoLocal) + .expect("test config") + .provider_contract() + .binding(role) + .expect("provider binding") + .clone() +} + +fn signed_event(sender: &Keys, receiver: &Keys, content: String, created_at: u64) -> Event { + EventBuilder::new(Kind::Custom(24_133), content) + .tag(Tag::public_key(receiver.public_key())) + .custom_created_at(Timestamp::from_secs(created_at)) + .sign_with_keys(sender) + .expect("signed event") +} + +fn verify_event( + event: &Event, + receiver_keys: &Keys, + policy: MycNip46AuthoredTimePolicy, +) -> Result<myc::MycVerifiedNip46Event, myc::MycNip46VerificationError> { + let wire = serde_json::to_vec(event).expect("event JSON"); + let bounded = admit_myc_nip46_event(limits(), &wire).expect("bounded event"); + verify_myc_nip46_event( + bounded, + &provider_binding(MycProviderRole::Transport, receiver_keys), + MycNip46ObservedAtUnixSeconds::new(OBSERVED_AT).expect("observation"), + policy, + ) +} + +fn request(id: &str, method: &str, params: Value) -> Vec<u8> { + serde_json::to_vec(&json!({"id": id, "method": method, "params": params})) + .expect("request JSON") +} + +#[test] +fn machine_contract_freezes_exact_step_boundary() { + let contract: Value = serde_json::from_str(CONTRACT).expect("verification contract"); + assert_eq!(contract["schema"], "radroots.myc.nip46-verification.v1"); + assert_eq!(contract["contract_version"], 1); + assert_eq!(contract["event"]["kind"], 24_133); + assert_eq!(contract["authored_time"]["default"], "none"); + assert_eq!( + contract["encryption"]["contexts"], + json!(["nip04", "nip44_v2"]) + ); + assert_eq!(contract["encryption"]["decryption"], "deferred_to_step_145"); + assert_eq!( + contract["request"]["replay_and_conflicting_reuse"], + "deferred_to_step_143" + ); +} + +#[test] +fn signed_nip44_and_nip04_events_verify_without_decrypting() { + let sender = keys(1); + let target = keys(2); + let policy = MycNip46AuthoredTimePolicy::new(60, 10).expect("policy"); + let plaintext = br#"{"id":"request-secret-marker","method":"ping","params":[]}"#; + + let nip44_content = nip44::encrypt( + sender.secret_key(), + &target.public_key(), + plaintext, + nip44::Version::V2, + ) + .expect("NIP-44 encrypt"); + let event = signed_event(&sender, &target, nip44_content.clone(), OBSERVED_AT); + let wire = serde_json::to_vec(&event).expect("event wire"); + let verified = verify_event(&event, &target, policy).expect("verified NIP-44 event"); + assert_eq!(verified.original_bytes(), wire); + assert_eq!(verified.event_id().as_bytes(), &event.id.to_bytes()); + assert_eq!( + verified.client_public_key().as_hex(), + sender.public_key().to_hex() + ); + assert_eq!(verified.authored_at_unix_seconds(), OBSERVED_AT); + assert_eq!( + verified.encryption_context(), + MycNip46EncryptionContext::Nip44V2 + ); + assert_eq!(verified.encrypted_content(), nip44_content); + + let nip04_content = nip04::encrypt(sender.secret_key(), &target.public_key(), plaintext) + .expect("NIP-04 encrypt"); + let event = signed_event(&sender, &target, nip04_content, OBSERVED_AT); + assert_eq!( + verify_event(&event, &target, policy) + .expect("verified NIP-04 event") + .encryption_context(), + MycNip46EncryptionContext::Nip04 + ); +} + +#[test] +fn canonical_kind_sender_and_exact_receiver_shape_fail_closed() { + let sender = keys(3); + let target = keys(4); + let policy = MycNip46AuthoredTimePolicy::new(60, 10).expect("policy"); + let content = BASE64_STANDARD.encode([vec![2], vec![0; 32], vec![0; 34], vec![0; 32]].concat()); + + let wrong_kind = EventBuilder::new(Kind::Custom(24_132), content.clone()) + .tag(Tag::public_key(target.public_key())) + .custom_created_at(Timestamp::from_secs(OBSERVED_AT)) + .sign_with_keys(&sender) + .expect("wrong-kind event"); + assert_eq!( + verify_event(&wrong_kind, &target, policy) + .unwrap_err() + .kind(), + MycNip46VerificationErrorKind::InvalidKind + ); + + let self_sent = signed_event(&target, &target, content.clone(), OBSERVED_AT); + assert_eq!( + verify_event(&self_sent, &target, policy) + .unwrap_err() + .kind(), + MycNip46VerificationErrorKind::InvalidSender + ); + + let wrong_target = keys(5); + let wrong_recipient = signed_event(&sender, &wrong_target, content.clone(), OBSERVED_AT); + assert_eq!( + verify_event(&wrong_recipient, &target, policy) + .unwrap_err() + .kind(), + MycNip46VerificationErrorKind::InvalidReceiver + ); + + let extra_tag = EventBuilder::new(Kind::Custom(24_133), content) + .tags([ + Tag::public_key(target.public_key()), + Tag::parse(["client", "untrusted-metadata"]).expect("extra tag"), + ]) + .custom_created_at(Timestamp::from_secs(OBSERVED_AT)) + .sign_with_keys(&sender) + .expect("extra-tag event"); + assert_eq!( + verify_event(&extra_tag, &target, policy) + .unwrap_err() + .kind(), + MycNip46VerificationErrorKind::InvalidReceiver + ); +} + +#[test] +fn identifiers_event_id_and_signature_are_independently_verified() { + let sender = keys(6); + let target = keys(7); + let policy = MycNip46AuthoredTimePolicy::new(60, 10).expect("policy"); + let payload = BASE64_STANDARD.encode([vec![2], vec![0; 32], vec![0; 34], vec![0; 32]].concat()); + let event = signed_event(&sender, &target, payload.clone(), OBSERVED_AT); + + let wire = String::from_utf8(serde_json::to_vec(&event).expect("wire")).expect("UTF-8"); + let uppercase = wire.replacen(&event.id.to_hex(), &event.id.to_hex().to_uppercase(), 1); + let bounded = admit_myc_nip46_event(limits(), uppercase.as_bytes()).expect("bounded uppercase"); + assert_eq!( + verify_myc_nip46_event( + bounded, + &provider_binding(MycProviderRole::Transport, &target), + MycNip46ObservedAtUnixSeconds::new(OBSERVED_AT).expect("observed"), + policy, + ) + .unwrap_err() + .kind(), + MycNip46VerificationErrorKind::NonCanonicalEvent + ); + + let mut wrong_id = event.clone(); + wrong_id.id = EventId::all_zeros(); + assert_eq!( + verify_event(&wrong_id, &target, policy).unwrap_err().kind(), + MycNip46VerificationErrorKind::InvalidEventId + ); + + let other = signed_event(&keys(8), &target, payload, OBSERVED_AT); + let mut wrong_signature = event; + wrong_signature.sig = other.sig; + assert_eq!( + verify_event(&wrong_signature, &target, policy) + .unwrap_err() + .kind(), + MycNip46VerificationErrorKind::InvalidSignature + ); +} + +#[test] +fn authored_time_policy_is_explicit_inclusive_and_overflow_safe() { + assert!(MycNip46ObservedAtUnixSeconds::new(0).is_err()); + assert!(MycNip46ObservedAtUnixSeconds::new(i64::MAX as u64).is_ok()); + assert!(MycNip46ObservedAtUnixSeconds::new(i64::MAX as u64 + 1).is_err()); + assert!(MycNip46AuthoredTimePolicy::new(i64::MAX as u64, 0).is_ok()); + assert!(MycNip46AuthoredTimePolicy::new(i64::MAX as u64 + 1, 0).is_err()); + + let sender = keys(9); + let target = keys(10); + let payload = BASE64_STANDARD.encode([vec![2], vec![0; 32], vec![0; 34], vec![0; 32]].concat()); + let policy = MycNip46AuthoredTimePolicy::new(10, 2).expect("policy"); + for accepted in [OBSERVED_AT - 10, OBSERVED_AT + 2] { + verify_event( + &signed_event(&sender, &target, payload.clone(), accepted), + &target, + policy, + ) + .expect("inclusive boundary"); + } + for rejected in [OBSERVED_AT - 11, OBSERVED_AT + 3] { + assert_eq!( + verify_event( + &signed_event(&sender, &target, payload.clone(), rejected), + &target, + policy, + ) + .unwrap_err() + .kind(), + MycNip46VerificationErrorKind::AuthoredTimeRejected + ); + } +} + +#[test] +fn only_the_configured_transport_role_can_supply_receiver_authority() { + let sender = keys(15); + let target = keys(16); + let payload = BASE64_STANDARD.encode([vec![2], vec![0; 32], vec![0; 34], vec![0; 32]].concat()); + let event = signed_event(&sender, &target, payload, OBSERVED_AT); + let wire = serde_json::to_vec(&event).expect("event wire"); + let bounded = admit_myc_nip46_event(limits(), &wire).expect("bounded event"); + let error = verify_myc_nip46_event( + bounded, + &provider_binding(MycProviderRole::User, &target), + MycNip46ObservedAtUnixSeconds::new(OBSERVED_AT).expect("observation"), + MycNip46AuthoredTimePolicy::new(0, 0).expect("policy"), + ) + .expect_err("user identity cannot become transport receiver authority"); + assert_eq!( + error.kind(), + MycNip46VerificationErrorKind::InvalidTransportBinding + ); +} + +#[test] +fn nip04_and_nip44_envelope_shapes_are_canonical_and_versioned() { + let sender = keys(11); + let target = keys(12); + let policy = MycNip46AuthoredTimePolicy::new(1, 1).expect("policy"); + + let invalid_nip04 = signed_event(&sender, &target, "AAAA?iv=AAAA".to_owned(), OBSERVED_AT); + assert_eq!( + verify_event(&invalid_nip04, &target, policy) + .unwrap_err() + .kind(), + MycNip46VerificationErrorKind::InvalidNip04Envelope + ); + + let mut wrong_version = vec![0; 99]; + wrong_version[0] = 3; + let event = signed_event( + &sender, + &target, + BASE64_STANDARD.encode(wrong_version), + OBSERVED_AT, + ); + assert_eq!( + verify_event(&event, &target, policy).unwrap_err().kind(), + MycNip46VerificationErrorKind::InvalidNip44Envelope + ); + + let mut invalid_padding = vec![0; 100]; + invalid_padding[0] = 2; + let event = signed_event( + &sender, + &target, + BASE64_STANDARD.encode(invalid_padding), + OBSERVED_AT, + ); + assert_eq!( + verify_event(&event, &target, policy).unwrap_err().kind(), + MycNip46VerificationErrorKind::InvalidNip44Envelope + ); +} + +#[test] +fn exact_typed_request_decoding_accepts_governed_and_custom_methods_only_when_semantic() { + let ping = request("request-01", "ping", json!([])); + let bounded = admit_myc_nip46_request(limits(), &ping).expect("bounded ping"); + let verified = verify_myc_nip46_request(bounded).expect("typed ping"); + assert_eq!(verified.request_id().as_str(), "request-01"); + assert_eq!(verified.method(), "ping"); + assert_eq!(verified.parameter_count(), 0); + assert_eq!(verified.parameter_bytes(), 0); + + let custom = request("custom-01", "vendor_method", json!(["a", "bc"])); + let bounded = admit_myc_nip46_request(limits(), &custom).expect("bounded custom"); + let verified = verify_myc_nip46_request(bounded).expect("typed custom"); + assert_eq!(verified.method(), "vendor_method"); + assert_eq!(verified.parameter_count(), 2); + assert_eq!(verified.parameter_bytes(), 3); + + for invalid in [ + request("", "ping", json!([])), + request(" request ", "ping", json!([])), + request("request-02", "ping", json!(["unexpected"])), + request("request-03", "nip44_encrypt", json!(["not-a-key", "x"])), + ] { + let bounded = admit_myc_nip46_request(limits(), &invalid).expect("structurally bounded"); + assert_eq!( + verify_myc_nip46_request(bounded).unwrap_err().kind(), + MycNip46VerificationErrorKind::InvalidTypedRequest + ); + } +} + +#[test] +fn verified_capabilities_and_errors_redact_protected_content() { + let sender = keys(13); + let target = keys(14); + let marker = br#"{"id":"protected-request-marker","method":"ping","params":[]}"#; + let ciphertext = nip44::encrypt( + sender.secret_key(), + &target.public_key(), + marker, + nip44::Version::V2, + ) + .expect("ciphertext"); + let event = signed_event(&sender, &target, ciphertext.clone(), OBSERVED_AT); + let verified_event = verify_event( + &event, + &target, + MycNip46AuthoredTimePolicy::new(0, 0).expect("policy"), + ) + .expect("verified event"); + let rendered = format!("{verified_event:?}"); + assert!(!rendered.contains(&ciphertext)); + assert!(!rendered.contains(&sender.public_key().to_hex())); + + let bounded = admit_myc_nip46_request(limits(), marker).expect("bounded request"); + let verified_request = verify_myc_nip46_request(bounded).expect("verified request"); + let rendered = format!("{verified_request:?}"); + assert!(!rendered.contains("protected-request-marker")); + assert!(!rendered.contains("ping")); + + for kind in [ + MycNip46VerificationErrorKind::InvalidObservationTime, + MycNip46VerificationErrorKind::InvalidTimePolicy, + MycNip46VerificationErrorKind::InvalidTransportBinding, + MycNip46VerificationErrorKind::MalformedEvent, + MycNip46VerificationErrorKind::NonCanonicalEvent, + MycNip46VerificationErrorKind::InvalidKind, + MycNip46VerificationErrorKind::InvalidSender, + MycNip46VerificationErrorKind::InvalidReceiver, + MycNip46VerificationErrorKind::InvalidEventId, + MycNip46VerificationErrorKind::InvalidSignature, + MycNip46VerificationErrorKind::AuthoredTimeRejected, + MycNip46VerificationErrorKind::InvalidNip04Envelope, + MycNip46VerificationErrorKind::InvalidNip44Envelope, + MycNip46VerificationErrorKind::InvalidTypedRequest, + ] { + let error = if kind == MycNip46VerificationErrorKind::InvalidObservationTime { + MycNip46ObservedAtUnixSeconds::new(0).unwrap_err() + } else if kind == MycNip46VerificationErrorKind::InvalidTimePolicy { + MycNip46AuthoredTimePolicy::new(u64::MAX, 0).unwrap_err() + } else { + continue; + }; + assert_eq!(error.kind(), kind); + assert!(!error.to_string().is_empty()); + assert!(error.source().is_none()); + } +}