myc

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

commit 9057f26564a68ecd2bb127d5fd4fdec41f6b8ffa
parent bc6470ee15473f4d88992c72ce2f80fd5d830ba4
Author: triesap <tyson@radroots.org>
Date:   Fri, 21 Aug 2026 23:54:57 +0000

feat(myc): bound NIP-46 admission

Diffstat:
MCargo.toml | 2+-
Mcontracts/api_baselines/myc.txt | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acontracts/services_hardening/nip46_admission.v1.json | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 7+++++++
Asrc/nip46_admission.rs | 913+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/package_boundary.rs | 11++++++++++-
Atests/services_hardening_nip46_admission.rs | 303+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 1359 insertions(+), 2 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml @@ -63,7 +63,7 @@ radroots_service_sqlite = { git = "https://github.com/radrootslabs/lib", rev = " radroots_secrets = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha", features = ["std"] } radroots_storage = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha", default-features = false } serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" +serde_json = { version = "1.0", features = ["raw_value"] } sha2 = "0.10" sqlx = { version = "0.9.0", default-features = false, features = ["derive", "sqlite-bundled"] } rustix = { version = "1", features = ["fs", "process", "std"] } diff --git a/contracts/api_baselines/myc.txt b/contracts/api_baselines/myc.txt @@ -278,6 +278,27 @@ pub myc::MycLocalSignerTransportErrorKind::Transport pub myc::MycLocalSignerTransportErrorKind::UnsupportedPlatform impl myc::MycLocalSignerTransportErrorKind pub const fn myc::MycLocalSignerTransportErrorKind::code(self) -> &'static str +pub enum myc::MycNip46AdmissionErrorKind +pub myc::MycNip46AdmissionErrorKind::EmptyEvent +pub myc::MycNip46AdmissionErrorKind::EmptyPlaintext +pub myc::MycNip46AdmissionErrorKind::EventContentTooLarge +pub myc::MycNip46AdmissionErrorKind::EventIdentifierTooLarge +pub myc::MycNip46AdmissionErrorKind::EventTooLarge +pub myc::MycNip46AdmissionErrorKind::InvalidEventUtf8 +pub myc::MycNip46AdmissionErrorKind::InvalidLimits +pub myc::MycNip46AdmissionErrorKind::InvalidPlaintextUtf8 +pub myc::MycNip46AdmissionErrorKind::MalformedEvent +pub myc::MycNip46AdmissionErrorKind::MalformedRequest +pub myc::MycNip46AdmissionErrorKind::PlaintextTooLarge +pub myc::MycNip46AdmissionErrorKind::RequestIdentifierTooLarge +pub myc::MycNip46AdmissionErrorKind::RequestMethodTooLarge +pub myc::MycNip46AdmissionErrorKind::RequestParameterTooLarge +pub myc::MycNip46AdmissionErrorKind::RequestParametersTooLarge +pub myc::MycNip46AdmissionErrorKind::TagElementTooLarge +pub myc::MycNip46AdmissionErrorKind::TagsTooLarge +pub myc::MycNip46AdmissionErrorKind::TooManyRequestParameters +pub myc::MycNip46AdmissionErrorKind::TooManyTagElements +pub myc::MycNip46AdmissionErrorKind::TooManyTags pub enum myc::MycProviderCapability pub myc::MycProviderCapability::Describe pub myc::MycProviderCapability::Nip04Decrypt @@ -520,6 +541,24 @@ pub fn myc::MycAuthorizationChallengeUrl::as_str(&self) -> &str pub fn myc::MycAuthorizationChallengeUrl::new(&str) -> core::result::Result<Self, myc::MycConnectionStateError> impl core::fmt::Debug for myc::MycAuthorizationChallengeUrl pub fn myc::MycAuthorizationChallengeUrl::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycBoundedNip46Event +impl myc::MycBoundedNip46Event +pub fn myc::MycBoundedNip46Event::encrypted_content(&self) -> &str +pub fn myc::MycBoundedNip46Event::original_bytes(&self) -> &[u8] +pub const fn myc::MycBoundedNip46Event::tag_bytes(&self) -> usize +pub const fn myc::MycBoundedNip46Event::tag_count(&self) -> usize +pub const fn myc::MycBoundedNip46Event::tag_element_count(&self) -> usize +impl core::fmt::Debug for myc::MycBoundedNip46Event +pub fn myc::MycBoundedNip46Event::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycBoundedNip46Request +impl myc::MycBoundedNip46Request +pub const fn myc::MycBoundedNip46Request::method_bytes(&self) -> usize +pub const fn myc::MycBoundedNip46Request::parameter_bytes(&self) -> usize +pub const fn myc::MycBoundedNip46Request::parameter_count(&self) -> usize +pub fn myc::MycBoundedNip46Request::plaintext_bytes(&self) -> usize +pub const fn myc::MycBoundedNip46Request::request_id_bytes(&self) -> usize +impl core::fmt::Debug for myc::MycBoundedNip46Request +pub fn myc::MycBoundedNip46Request::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct myc::MycCliInvocationV1 impl myc::MycCliInvocationV1 pub const fn myc::MycCliInvocationV1::command(&self) -> myc::MycCommandV1 @@ -849,6 +888,26 @@ impl myc::MycNip05ProjectionDigest pub const fn myc::MycNip05ProjectionDigest::as_bytes(&self) -> &[u8; 32] impl core::fmt::Debug for myc::MycNip05ProjectionDigest pub fn myc::MycNip05ProjectionDigest::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycNip46AdmissionError +impl myc::MycNip46AdmissionError +pub const fn myc::MycNip46AdmissionError::kind(self) -> myc::MycNip46AdmissionErrorKind +impl core::error::Error for myc::MycNip46AdmissionError +impl core::fmt::Debug for myc::MycNip46AdmissionError +pub fn myc::MycNip46AdmissionError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for myc::MycNip46AdmissionError +pub fn myc::MycNip46AdmissionError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycNip46AdmissionLimits +impl myc::MycNip46AdmissionLimits +pub const fn myc::MycNip46AdmissionLimits::decrypted_plaintext_bytes(self) -> usize +pub const fn myc::MycNip46AdmissionLimits::event_content_bytes(self) -> usize +pub const fn myc::MycNip46AdmissionLimits::event_tag_count(self) -> usize +pub const fn myc::MycNip46AdmissionLimits::event_tag_element_bytes(self) -> usize +pub const fn myc::MycNip46AdmissionLimits::event_tag_total_bytes(self) -> usize +pub const fn myc::MycNip46AdmissionLimits::event_tag_total_elements(self) -> usize +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::MycNip46ClientPublicKey(_) impl myc::MycNip46ClientPublicKey pub fn myc::MycNip46ClientPublicKey::as_hex(&self) -> &str @@ -1233,7 +1292,10 @@ pub const myc::MYC_LOCAL_SIGNER_TRANSPORT_CONTRACT_VERSION: u32 pub const myc::MYC_MIGRATION_CATALOG_SHA256: [u8; 32] pub const myc::MYC_NIP05_PROJECTION_MAX_BYTES: usize pub const myc::MYC_NIP46_CANONICAL_REQUEST_MAX_BYTES: usize +pub const myc::MYC_NIP46_EVENT_ID_MAX_BYTES: usize +pub const myc::MYC_NIP46_PUBLIC_KEY_MAX_BYTES: usize pub const myc::MYC_NIP46_REQUEST_ID_MAX_UTF8_BYTES: usize +pub const myc::MYC_NIP46_SIGNATURE_MAX_BYTES: usize pub const myc::MYC_OPERATOR_CONTRACT_VERSION: u32 pub const myc::MYC_PROVIDER_CONCURRENCY_MAX: u32 pub const myc::MYC_PROVIDER_CONTRACT_VERSION: u32 @@ -1275,6 +1337,8 @@ pub const myc::MYC_STATE_SCHEMA_VERSION_7_OBJECT_COUNT: u32 pub const myc::MYC_STATE_SCHEMA_VERSION_7_SHA256: [u8; 32] pub const myc::MYC_WRAPPING_CREDENTIAL_ARTIFACT_BYTES: usize pub const myc::MYC_WRAPPING_CREDENTIAL_CONTRACT_VERSION: u32 +pub fn myc::admit_myc_nip46_event(myc::MycNip46AdmissionLimits, &[u8]) -> core::result::Result<myc::MycBoundedNip46Event, myc::MycNip46AdmissionError> +pub fn myc::admit_myc_nip46_request(myc::MycNip46AdmissionLimits, &[u8]) -> core::result::Result<myc::MycBoundedNip46Request, myc::MycNip46AdmissionError> pub async fn myc::finalize_myc_state_restore(myc::MycStagedStateRestore) -> core::result::Result<(), myc::MycStateMaintenanceError> pub async fn myc::initialize_myc_state(&myc::MycRuntimeContext, &myc::MycStateMetadata, radroots_service_sqlite::migration::MigrationAppliedAtUnixSeconds, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<(), myc::MycStateHostError> pub fn myc::myc_migration_catalog() -> core::result::Result<radroots_service_sqlite::migration::MigrationCatalog, myc::MycStateCatalogError> diff --git a/contracts/services_hardening/nip46_admission.v1.json b/contracts/services_hardening/nip46_admission.v1.json @@ -0,0 +1,61 @@ +{ + "schema": "radroots.myc.nip46-admission.v1", + "contract_version": 1, + "source": "validated_config_resource_limits_events", + "event": { + "original_wire_cap_before_parse": true, + "utf8": "required", + "object_fields": ["id", "pubkey", "created_at", "kind", "tags", "content", "sig"], + "unknown_fields": "reject", + "duplicate_fields": "reject", + "null_fields": "reject", + "identifier_max_bytes": { + "id": 64, + "pubkey": 64, + "sig": 128 + }, + "configured_bounds": [ + "wire_bytes", + "content_bytes", + "tag_count", + "tag_total_elements", + "tag_element_bytes", + "tag_total_bytes" + ], + "decryption": "forbidden_before_admission" + }, + "request": { + "plaintext_cap_before_parse": true, + "utf8": "required", + "object_fields": ["id", "method", "params"], + "unknown_fields": "reject", + "duplicate_fields": "reject", + "null_fields": "reject", + "configured_bound": "decrypted_plaintext_bytes", + "library_bounds": { + "request_id_bytes": 128, + "method_bytes": 64, + "parameter_count": 64, + "parameter_bytes": 65536, + "aggregate_parameter_bytes": 262144 + }, + "typed_decode": "deferred_to_step_142" + }, + "retention": { + "event_original_bytes": "exact", + "event_ciphertext": "bounded", + "request_plaintext": "opaque_and_redacted" + }, + "errors": "crate_owned_source_free_redacted", + "deferred": [ + "event_id_and_signature_verification", + "sender_receiver_kind_and_timestamp_policy", + "nip04_nip44_context", + "typed_nip46_request", + "replay_and_deduplication", + "authorization", + "provider_execution", + "persistence", + "relay_io" + ] +} diff --git a/src/lib.rs b/src/lib.rs @@ -3,6 +3,7 @@ mod cli_v1; mod config_v1; +mod nip46_admission; mod provider_contract; mod provider_credential; mod provider_envelope; @@ -30,6 +31,12 @@ pub use config_v1::{ MycConfigDocumentV1, MycConfigProfile, MycConfigV1Error, MycConfigV1ErrorKind, MycConfigValueSource, MycEffectiveConfigV1, parse_myc_config_v1, }; +pub use nip46_admission::{ + MYC_NIP46_EVENT_ID_MAX_BYTES, MYC_NIP46_PUBLIC_KEY_MAX_BYTES, MYC_NIP46_SIGNATURE_MAX_BYTES, + MycBoundedNip46Event, MycBoundedNip46Request, MycNip46AdmissionError, + MycNip46AdmissionErrorKind, MycNip46AdmissionLimits, admit_myc_nip46_event, + admit_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 @@ -0,0 +1,913 @@ +//! Allocation-bounded structural admission for encrypted NIP-46 events and plaintext requests. + +use core::fmt; +use std::error::Error; + +use radroots_nostr_connect::{ + message::{ + REQUEST_ID_MAX_BYTES, REQUEST_PARAM_COUNT_MAX, REQUEST_PARAM_MAX_BYTES, + REQUEST_PARAMS_MAX_BYTES, + }, + method::METHOD_MAX_BYTES, +}; +use serde::Deserialize; +use serde::de::{self, DeserializeSeed, IgnoredAny, SeqAccess, Visitor}; +use serde_json::value::RawValue; + +use crate::MycConfigDocumentV1; + +/// Maximum encoded Nostr event identifier length admitted before event parsing. +pub const MYC_NIP46_EVENT_ID_MAX_BYTES: usize = 64; + +/// Maximum encoded Nostr public-key length admitted before event parsing. +pub const MYC_NIP46_PUBLIC_KEY_MAX_BYTES: usize = 64; + +/// Maximum encoded Nostr signature length admitted before event parsing. +pub const MYC_NIP46_SIGNATURE_MAX_BYTES: usize = 128; + +const TAG_COUNT_SENTINEL: &str = "myc-tag-count-limit"; +const TAG_ELEMENT_COUNT_SENTINEL: &str = "myc-tag-element-count-limit"; +const TAG_ELEMENT_BYTES_SENTINEL: &str = "myc-tag-element-bytes-limit"; +const TAG_TOTAL_BYTES_SENTINEL: &str = "myc-tag-total-bytes-limit"; +const PARAM_COUNT_SENTINEL: &str = "myc-param-count-limit"; +const PARAM_BYTES_SENTINEL: &str = "myc-param-bytes-limit"; +const PARAM_TOTAL_BYTES_SENTINEL: &str = "myc-param-total-bytes-limit"; + +/// Immutable limits projected from one admitted Myc configuration document. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct MycNip46AdmissionLimits { + event_wire_bytes: usize, + event_content_bytes: usize, + event_tag_count: usize, + event_tag_total_elements: usize, + event_tag_element_bytes: usize, + event_tag_total_bytes: usize, + decrypted_plaintext_bytes: usize, +} + +impl MycNip46AdmissionLimits { + /// Projects the exact event limits from a validated immutable configuration. + pub fn from_config( + configuration: &MycConfigDocumentV1, + ) -> Result<Self, MycNip46AdmissionError> { + Ok(Self { + event_wire_bytes: config_limit(configuration, "/resource_limits/events/wire_bytes")?, + event_content_bytes: config_limit( + configuration, + "/resource_limits/events/content_bytes", + )?, + event_tag_count: config_limit(configuration, "/resource_limits/events/tag_count")?, + event_tag_total_elements: config_limit( + configuration, + "/resource_limits/events/tag_total_elements", + )?, + event_tag_element_bytes: config_limit( + configuration, + "/resource_limits/events/tag_element_bytes", + )?, + event_tag_total_bytes: config_limit( + configuration, + "/resource_limits/events/tag_total_bytes", + )?, + decrypted_plaintext_bytes: config_limit( + configuration, + "/resource_limits/events/decrypted_plaintext_bytes", + )?, + }) + } + + /// Returns the original event-wire byte cap. + #[must_use] + pub const fn event_wire_bytes(self) -> usize { + self.event_wire_bytes + } + + /// Returns the decoded event-content byte cap. + #[must_use] + pub const fn event_content_bytes(self) -> usize { + self.event_content_bytes + } + + /// Returns the outer event-tag count cap. + #[must_use] + pub const fn event_tag_count(self) -> usize { + self.event_tag_count + } + + /// Returns the aggregate tag-element count cap. + #[must_use] + pub const fn event_tag_total_elements(self) -> usize { + self.event_tag_total_elements + } + + /// Returns the decoded byte cap for one tag element. + #[must_use] + pub const fn event_tag_element_bytes(self) -> usize { + self.event_tag_element_bytes + } + + /// Returns the aggregate decoded tag-element byte cap. + #[must_use] + pub const fn event_tag_total_bytes(self) -> usize { + self.event_tag_total_bytes + } + + /// Returns the decrypted NIP-46 plaintext byte cap. + #[must_use] + pub const fn decrypted_plaintext_bytes(self) -> usize { + self.decrypted_plaintext_bytes + } +} + +impl fmt::Debug for MycNip46AdmissionLimits { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycNip46AdmissionLimits") + .field("event_wire_bytes", &self.event_wire_bytes) + .field("event_content_bytes", &self.event_content_bytes) + .field("event_tag_count", &self.event_tag_count) + .field("event_tag_total_elements", &self.event_tag_total_elements) + .field("event_tag_element_bytes", &self.event_tag_element_bytes) + .field("event_tag_total_bytes", &self.event_tag_total_bytes) + .field("decrypted_plaintext_bytes", &self.decrypted_plaintext_bytes) + .finish() + } +} + +/// Stable source-free classification for NIP-46 resource-admission failures. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycNip46AdmissionErrorKind { + InvalidLimits, + EmptyEvent, + EventTooLarge, + InvalidEventUtf8, + MalformedEvent, + EventIdentifierTooLarge, + EventContentTooLarge, + TooManyTags, + TooManyTagElements, + TagElementTooLarge, + TagsTooLarge, + EmptyPlaintext, + PlaintextTooLarge, + InvalidPlaintextUtf8, + MalformedRequest, + RequestIdentifierTooLarge, + RequestMethodTooLarge, + TooManyRequestParameters, + RequestParameterTooLarge, + RequestParametersTooLarge, +} + +impl MycNip46AdmissionErrorKind { + const fn message(self) -> &'static str { + match self { + Self::InvalidLimits => "NIP-46 admission limits are invalid", + Self::EmptyEvent => "NIP-46 event bytes are empty", + Self::EventTooLarge => "NIP-46 event exceeds its wire limit", + Self::InvalidEventUtf8 => "NIP-46 event is not valid UTF-8", + Self::MalformedEvent => "NIP-46 event structure is invalid", + Self::EventIdentifierTooLarge => "NIP-46 event identifier exceeds its limit", + Self::EventContentTooLarge => "NIP-46 event content exceeds its limit", + Self::TooManyTags => "NIP-46 event tag count exceeds its limit", + Self::TooManyTagElements => "NIP-46 event tag elements exceed their count limit", + Self::TagElementTooLarge => "NIP-46 event tag element exceeds its byte limit", + Self::TagsTooLarge => "NIP-46 event tags exceed their aggregate byte limit", + Self::EmptyPlaintext => "NIP-46 request plaintext is empty", + Self::PlaintextTooLarge => "NIP-46 request plaintext exceeds its limit", + Self::InvalidPlaintextUtf8 => "NIP-46 request plaintext is not valid UTF-8", + Self::MalformedRequest => "NIP-46 request structure is invalid", + Self::RequestIdentifierTooLarge => "NIP-46 request identifier exceeds its limit", + Self::RequestMethodTooLarge => "NIP-46 request method exceeds its limit", + Self::TooManyRequestParameters => "NIP-46 request parameter count exceeds its limit", + Self::RequestParameterTooLarge => "NIP-46 request parameter exceeds its byte limit", + Self::RequestParametersTooLarge => { + "NIP-46 request parameters exceed their aggregate byte limit" + } + } + } +} + +/// One redacted NIP-46 resource-admission failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct MycNip46AdmissionError { + kind: MycNip46AdmissionErrorKind, +} + +impl MycNip46AdmissionError { + const fn new(kind: MycNip46AdmissionErrorKind) -> Self { + Self { kind } + } + + /// Returns the stable failure classification. + #[must_use] + pub const fn kind(self) -> MycNip46AdmissionErrorKind { + self.kind + } +} + +impl fmt::Debug for MycNip46AdmissionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycNip46AdmissionError") + .field("kind", &self.kind) + .finish() + } +} + +impl fmt::Display for MycNip46AdmissionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.kind.message()) + } +} + +impl Error for MycNip46AdmissionError {} + +/// One structurally admitted encrypted NIP-46 event. +pub struct MycBoundedNip46Event { + original: Box<[u8]>, + encrypted_content: Box<str>, + tag_count: usize, + tag_element_count: usize, + tag_bytes: usize, +} + +impl MycBoundedNip46Event { + /// Returns the exact original event bytes retained for later verification. + #[must_use] + pub fn original_bytes(&self) -> &[u8] { + &self.original + } + + /// Returns the bounded ciphertext without decrypting it. + #[must_use] + pub fn encrypted_content(&self) -> &str { + &self.encrypted_content + } + + /// Returns the number of admitted tags. + #[must_use] + pub const fn tag_count(&self) -> usize { + self.tag_count + } + + /// Returns the aggregate number of admitted tag elements. + #[must_use] + pub const fn tag_element_count(&self) -> usize { + self.tag_element_count + } + + /// Returns the aggregate decoded UTF-8 bytes in all tag elements. + #[must_use] + pub const fn tag_bytes(&self) -> usize { + self.tag_bytes + } +} + +impl fmt::Debug for MycBoundedNip46Event { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycBoundedNip46Event") + .field("wire_bytes", &self.original.len()) + .field("content_bytes", &self.encrypted_content.len()) + .field("tag_count", &self.tag_count) + .field("tag_element_count", &self.tag_element_count) + .field("tag_bytes", &self.tag_bytes) + .finish() + } +} + +/// One structurally admitted decrypted NIP-46 request. +pub struct MycBoundedNip46Request { + plaintext: Box<[u8]>, + request_id_bytes: usize, + method_bytes: usize, + parameter_count: usize, + parameter_bytes: usize, +} + +impl MycBoundedNip46Request { + /// Returns the admitted plaintext byte count without exposing its content. + #[must_use] + pub fn plaintext_bytes(&self) -> usize { + self.plaintext.len() + } + + /// Returns the decoded request-identifier byte count. + #[must_use] + pub const fn request_id_bytes(&self) -> usize { + self.request_id_bytes + } + + /// Returns the decoded request-method byte count. + #[must_use] + pub const fn method_bytes(&self) -> usize { + self.method_bytes + } + + /// Returns the admitted request-parameter count. + #[must_use] + pub const fn parameter_count(&self) -> usize { + self.parameter_count + } + + /// Returns aggregate decoded UTF-8 bytes in request parameters. + #[must_use] + pub const fn parameter_bytes(&self) -> usize { + self.parameter_bytes + } +} + +impl fmt::Debug for MycBoundedNip46Request { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycBoundedNip46Request") + .field("plaintext_bytes", &self.plaintext.len()) + .field("request_id_bytes", &self.request_id_bytes) + .field("method_bytes", &self.method_bytes) + .field("parameter_count", &self.parameter_count) + .field("parameter_bytes", &self.parameter_bytes) + .finish() + } +} + +/// Bounds and structurally admits original encrypted event bytes before decryption. +pub fn admit_myc_nip46_event( + limits: MycNip46AdmissionLimits, + original: &[u8], +) -> Result<MycBoundedNip46Event, MycNip46AdmissionError> { + if original.is_empty() { + return Err(failure(MycNip46AdmissionErrorKind::EmptyEvent)); + } + if original.len() > limits.event_wire_bytes { + return Err(failure(MycNip46AdmissionErrorKind::EventTooLarge)); + } + let source = std::str::from_utf8(original) + .map_err(|_| failure(MycNip46AdmissionErrorKind::InvalidEventUtf8))?; + let raw: RawEvent<'_> = parse_exact(source, MycNip46AdmissionErrorKind::MalformedEvent)?; + + validate_bounded_string( + raw.id, + MYC_NIP46_EVENT_ID_MAX_BYTES, + MycNip46AdmissionErrorKind::EventIdentifierTooLarge, + MycNip46AdmissionErrorKind::MalformedEvent, + )?; + validate_bounded_string( + raw.pubkey, + MYC_NIP46_PUBLIC_KEY_MAX_BYTES, + MycNip46AdmissionErrorKind::EventIdentifierTooLarge, + MycNip46AdmissionErrorKind::MalformedEvent, + )?; + validate_bounded_string( + raw.sig, + MYC_NIP46_SIGNATURE_MAX_BYTES, + MycNip46AdmissionErrorKind::EventIdentifierTooLarge, + MycNip46AdmissionErrorKind::MalformedEvent, + )?; + parse_scalar::<u64>(raw.created_at, MycNip46AdmissionErrorKind::MalformedEvent)?; + parse_scalar::<u64>(raw.kind, MycNip46AdmissionErrorKind::MalformedEvent)?; + let encrypted_content = decode_bounded_string( + raw.content, + limits.event_content_bytes, + MycNip46AdmissionErrorKind::EventContentTooLarge, + MycNip46AdmissionErrorKind::MalformedEvent, + )?; + let tags = measure_tags(raw.tags, limits)?; + + Ok(MycBoundedNip46Event { + original: original.into(), + encrypted_content: encrypted_content.into_boxed_str(), + tag_count: tags.count, + tag_element_count: tags.elements, + tag_bytes: tags.bytes, + }) +} + +/// Bounds and structurally admits decrypted request bytes before typed decoding. +pub fn admit_myc_nip46_request( + limits: MycNip46AdmissionLimits, + plaintext: &[u8], +) -> Result<MycBoundedNip46Request, MycNip46AdmissionError> { + if plaintext.is_empty() { + return Err(failure(MycNip46AdmissionErrorKind::EmptyPlaintext)); + } + if plaintext.len() > limits.decrypted_plaintext_bytes { + return Err(failure(MycNip46AdmissionErrorKind::PlaintextTooLarge)); + } + let source = std::str::from_utf8(plaintext) + .map_err(|_| failure(MycNip46AdmissionErrorKind::InvalidPlaintextUtf8))?; + let raw: RawRequest<'_> = parse_exact(source, MycNip46AdmissionErrorKind::MalformedRequest)?; + let request_id_bytes = validate_bounded_string( + raw.id, + REQUEST_ID_MAX_BYTES, + MycNip46AdmissionErrorKind::RequestIdentifierTooLarge, + MycNip46AdmissionErrorKind::MalformedRequest, + )?; + let method_bytes = validate_bounded_string( + raw.method, + METHOD_MAX_BYTES, + MycNip46AdmissionErrorKind::RequestMethodTooLarge, + MycNip46AdmissionErrorKind::MalformedRequest, + )?; + let parameters = measure_parameters(raw.params)?; + + Ok(MycBoundedNip46Request { + plaintext: plaintext.into(), + request_id_bytes, + method_bytes, + parameter_count: parameters.count, + parameter_bytes: parameters.bytes, + }) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawEvent<'a> { + #[serde(borrow)] + id: &'a RawValue, + #[serde(borrow)] + pubkey: &'a RawValue, + #[serde(borrow)] + created_at: &'a RawValue, + #[serde(borrow)] + kind: &'a RawValue, + #[serde(borrow)] + tags: &'a RawValue, + #[serde(borrow)] + content: &'a RawValue, + #[serde(borrow)] + sig: &'a RawValue, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawRequest<'a> { + #[serde(borrow)] + id: &'a RawValue, + #[serde(borrow)] + method: &'a RawValue, + #[serde(borrow)] + params: &'a RawValue, +} + +#[derive(Clone, Copy)] +struct Measurement { + count: usize, + elements: usize, + bytes: usize, +} + +fn config_limit( + configuration: &MycConfigDocumentV1, + pointer: &str, +) -> Result<usize, MycNip46AdmissionError> { + configuration + .normalized() + .pointer(pointer) + .and_then(serde_json::Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) + .filter(|value| *value > 0) + .ok_or_else(|| failure(MycNip46AdmissionErrorKind::InvalidLimits)) +} + +fn parse_exact<'a, T>( + source: &'a str, + malformed: MycNip46AdmissionErrorKind, +) -> Result<T, MycNip46AdmissionError> +where + T: Deserialize<'a>, +{ + let mut deserializer = serde_json::Deserializer::from_str(source); + let value = T::deserialize(&mut deserializer).map_err(|_| failure(malformed))?; + deserializer.end().map_err(|_| failure(malformed))?; + Ok(value) +} + +fn parse_scalar<T>( + raw: &RawValue, + malformed: MycNip46AdmissionErrorKind, +) -> Result<T, MycNip46AdmissionError> +where + T: serde::de::DeserializeOwned, +{ + serde_json::from_str(raw.get()).map_err(|_| failure(malformed)) +} + +fn validate_bounded_string( + raw: &RawValue, + maximum: usize, + too_large: MycNip46AdmissionErrorKind, + malformed: MycNip46AdmissionErrorKind, +) -> Result<usize, MycNip46AdmissionError> { + let length = decoded_json_string_utf8_bytes(raw.get()).ok_or_else(|| failure(malformed))?; + if length > maximum { + return Err(failure(too_large)); + } + Ok(length) +} + +fn decode_bounded_string( + raw: &RawValue, + maximum: usize, + too_large: MycNip46AdmissionErrorKind, + malformed: MycNip46AdmissionErrorKind, +) -> Result<String, MycNip46AdmissionError> { + validate_bounded_string(raw, maximum, too_large, malformed)?; + serde_json::from_str(raw.get()).map_err(|_| failure(malformed)) +} + +fn decoded_json_string_utf8_bytes(raw: &str) -> Option<usize> { + let bytes = raw.as_bytes(); + if bytes.len() < 2 || bytes.first() != Some(&b'"') || bytes.last() != Some(&b'"') { + return None; + } + let end = bytes.len() - 1; + let mut index = 1; + let mut length = 0usize; + while index < end { + let byte = bytes[index]; + if byte == b'\\' { + index = index.checked_add(1)?; + let escaped = *bytes.get(index)?; + match escaped { + b'"' | b'\\' | b'/' | b'b' | b'f' | b'n' | b'r' | b't' => { + length = length.checked_add(1)?; + index = index.checked_add(1)?; + } + b'u' => { + let first = parse_hex_u16(bytes.get(index + 1..index + 5)?)?; + index = index.checked_add(5)?; + let scalar = if (0xd800..=0xdbff).contains(&first) { + if bytes.get(index..index + 2)? != b"\\u" { + return None; + } + let second = parse_hex_u16(bytes.get(index + 2..index + 6)?)?; + if !(0xdc00..=0xdfff).contains(&second) { + return None; + } + index = index.checked_add(6)?; + 0x1_0000 + + ((u32::from(first) - 0xd800) << 10) + + (u32::from(second) - 0xdc00) + } else if (0xdc00..=0xdfff).contains(&first) { + return None; + } else { + u32::from(first) + }; + length = length.checked_add(char::from_u32(scalar)?.len_utf8())?; + } + _ => return None, + } + } else if byte < 0x80 { + if byte < 0x20 || byte == b'"' { + return None; + } + length = length.checked_add(1)?; + index = index.checked_add(1)?; + } else { + let character = raw.get(index..end)?.chars().next()?; + let width = character.len_utf8(); + length = length.checked_add(width)?; + index = index.checked_add(width)?; + } + } + (index == end).then_some(length) +} + +fn parse_hex_u16(bytes: &[u8]) -> Option<u16> { + if bytes.len() != 4 { + return None; + } + bytes.iter().try_fold(0u16, |value, byte| { + let digit = match byte { + b'0'..=b'9' => u16::from(byte - b'0'), + b'a'..=b'f' => u16::from(byte - b'a') + 10, + b'A'..=b'F' => u16::from(byte - b'A') + 10, + _ => return None, + }; + value.checked_mul(16)?.checked_add(digit) + }) +} + +fn measure_tags( + raw: &RawValue, + limits: MycNip46AdmissionLimits, +) -> Result<Measurement, MycNip46AdmissionError> { + let mut deserializer = serde_json::Deserializer::from_str(raw.get()); + let result = TagsSeed { limits }.deserialize(&mut deserializer); + let measurement = result.map_err(classify_tag_error)?; + deserializer + .end() + .map_err(|_| failure(MycNip46AdmissionErrorKind::MalformedEvent))?; + Ok(measurement) +} + +struct TagsSeed { + limits: MycNip46AdmissionLimits, +} + +impl<'de> DeserializeSeed<'de> for TagsSeed { + type Value = Measurement; + + fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error> + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_seq(TagsVisitor { + limits: self.limits, + }) + } +} + +struct TagsVisitor { + limits: MycNip46AdmissionLimits, +} + +impl<'de> Visitor<'de> for TagsVisitor { + type Value = Measurement; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a bounded array of Nostr tags") + } + + fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error> + where + A: SeqAccess<'de>, + { + let mut result = Measurement { + count: 0, + elements: 0, + bytes: 0, + }; + while result.count < self.limits.event_tag_count { + let remaining_elements = self + .limits + .event_tag_total_elements + .checked_sub(result.elements) + .ok_or_else(|| de::Error::custom(TAG_ELEMENT_COUNT_SENTINEL))?; + let Some(tag) = sequence.next_element_seed(TagSeed { + maximum_elements: remaining_elements, + maximum_element_bytes: self.limits.event_tag_element_bytes, + })? + else { + return Ok(result); + }; + result.count += 1; + result.elements = result + .elements + .checked_add(tag.elements) + .ok_or_else(|| de::Error::custom(TAG_ELEMENT_COUNT_SENTINEL))?; + result.bytes = result + .bytes + .checked_add(tag.bytes) + .ok_or_else(|| de::Error::custom(TAG_TOTAL_BYTES_SENTINEL))?; + if result.bytes > self.limits.event_tag_total_bytes { + return Err(de::Error::custom(TAG_TOTAL_BYTES_SENTINEL)); + } + } + if sequence.next_element::<IgnoredAny>()?.is_some() { + return Err(de::Error::custom(TAG_COUNT_SENTINEL)); + } + Ok(result) + } +} + +struct TagSeed { + maximum_elements: usize, + maximum_element_bytes: usize, +} + +impl<'de> DeserializeSeed<'de> for TagSeed { + type Value = Measurement; + + fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error> + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_seq(TagVisitor { + maximum_elements: self.maximum_elements, + maximum_element_bytes: self.maximum_element_bytes, + }) + } +} + +struct TagVisitor { + maximum_elements: usize, + maximum_element_bytes: usize, +} + +impl<'de> Visitor<'de> for TagVisitor { + type Value = Measurement; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a bounded Nostr tag") + } + + fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error> + where + A: SeqAccess<'de>, + { + let mut result = Measurement { + count: 1, + elements: 0, + bytes: 0, + }; + while result.elements < self.maximum_elements { + let Some(length) = sequence.next_element_seed(StringLengthSeed { + maximum: self.maximum_element_bytes, + sentinel: TAG_ELEMENT_BYTES_SENTINEL, + })? + else { + return Ok(result); + }; + result.elements += 1; + result.bytes = result + .bytes + .checked_add(length) + .ok_or_else(|| de::Error::custom(TAG_TOTAL_BYTES_SENTINEL))?; + } + if sequence.next_element::<IgnoredAny>()?.is_some() { + return Err(de::Error::custom(TAG_ELEMENT_COUNT_SENTINEL)); + } + Ok(result) + } +} + +fn measure_parameters(raw: &RawValue) -> Result<Measurement, MycNip46AdmissionError> { + let mut deserializer = serde_json::Deserializer::from_str(raw.get()); + let result = StringSequenceSeed { + maximum_count: REQUEST_PARAM_COUNT_MAX, + maximum_element_bytes: REQUEST_PARAM_MAX_BYTES, + maximum_total_bytes: REQUEST_PARAMS_MAX_BYTES, + count_sentinel: PARAM_COUNT_SENTINEL, + element_sentinel: PARAM_BYTES_SENTINEL, + total_sentinel: PARAM_TOTAL_BYTES_SENTINEL, + } + .deserialize(&mut deserializer); + let measurement = result.map_err(classify_param_error)?; + deserializer + .end() + .map_err(|_| failure(MycNip46AdmissionErrorKind::MalformedRequest))?; + Ok(measurement) +} + +struct StringSequenceSeed { + maximum_count: usize, + maximum_element_bytes: usize, + maximum_total_bytes: usize, + count_sentinel: &'static str, + element_sentinel: &'static str, + total_sentinel: &'static str, +} + +impl<'de> DeserializeSeed<'de> for StringSequenceSeed { + type Value = Measurement; + + fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error> + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_seq(StringSequenceVisitor { seed: self }) + } +} + +struct StringSequenceVisitor { + seed: StringSequenceSeed, +} + +impl<'de> Visitor<'de> for StringSequenceVisitor { + type Value = Measurement; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a bounded array of strings") + } + + fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error> + where + A: SeqAccess<'de>, + { + let mut result = Measurement { + count: 0, + elements: 0, + bytes: 0, + }; + while result.count < self.seed.maximum_count { + let Some(length) = sequence.next_element_seed(StringLengthSeed { + maximum: self.seed.maximum_element_bytes, + sentinel: self.seed.element_sentinel, + })? + else { + return Ok(result); + }; + result.count += 1; + result.elements = result.count; + result.bytes = result + .bytes + .checked_add(length) + .ok_or_else(|| de::Error::custom(self.seed.total_sentinel))?; + if result.bytes > self.seed.maximum_total_bytes { + return Err(de::Error::custom(self.seed.total_sentinel)); + } + } + if sequence.next_element::<IgnoredAny>()?.is_some() { + return Err(de::Error::custom(self.seed.count_sentinel)); + } + Ok(result) + } +} + +struct StringLengthSeed { + maximum: usize, + sentinel: &'static str, +} + +impl<'de> DeserializeSeed<'de> for StringLengthSeed { + type Value = usize; + + fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error> + where + D: serde::Deserializer<'de>, + { + let raw = <&RawValue>::deserialize(deserializer)?; + let length = decoded_json_string_utf8_bytes(raw.get()) + .ok_or_else(|| de::Error::invalid_type(de::Unexpected::Other("non-string"), &self))?; + if length > self.maximum { + return Err(de::Error::custom(self.sentinel)); + } + Ok(length) + } +} + +impl de::Expected for StringLengthSeed { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a JSON string") + } +} + +fn classify_tag_error(error: serde_json::Error) -> MycNip46AdmissionError { + let rendered = error.to_string(); + let kind = if rendered.contains(TAG_COUNT_SENTINEL) { + MycNip46AdmissionErrorKind::TooManyTags + } else if rendered.contains(TAG_ELEMENT_COUNT_SENTINEL) { + MycNip46AdmissionErrorKind::TooManyTagElements + } else if rendered.contains(TAG_ELEMENT_BYTES_SENTINEL) { + MycNip46AdmissionErrorKind::TagElementTooLarge + } else if rendered.contains(TAG_TOTAL_BYTES_SENTINEL) { + MycNip46AdmissionErrorKind::TagsTooLarge + } else { + MycNip46AdmissionErrorKind::MalformedEvent + }; + failure(kind) +} + +fn classify_param_error(error: serde_json::Error) -> MycNip46AdmissionError { + let rendered = error.to_string(); + let kind = if rendered.contains(PARAM_COUNT_SENTINEL) { + MycNip46AdmissionErrorKind::TooManyRequestParameters + } else if rendered.contains(PARAM_BYTES_SENTINEL) { + MycNip46AdmissionErrorKind::RequestParameterTooLarge + } else if rendered.contains(PARAM_TOTAL_BYTES_SENTINEL) { + MycNip46AdmissionErrorKind::RequestParametersTooLarge + } else { + MycNip46AdmissionErrorKind::MalformedRequest + }; + failure(kind) +} + +const fn failure(kind: MycNip46AdmissionErrorKind) -> MycNip46AdmissionError { + MycNip46AdmissionError::new(kind) +} + +#[cfg(test)] +mod tests { + use super::{MycNip46AdmissionErrorKind, RawValue, measure_parameters}; + + fn raw_parameters(lengths: &[usize]) -> Box<RawValue> { + let encoded = serde_json::to_string( + &lengths + .iter() + .map(|length| "x".repeat(*length)) + .collect::<Vec<_>>(), + ) + .expect("parameter JSON"); + RawValue::from_string(encoded).expect("raw parameter JSON") + } + + #[test] + fn request_parameter_aggregate_bound_is_exact_and_independent() { + let exact = raw_parameters(&[65_536, 65_536, 65_536, 65_536]); + let measurement = measure_parameters(&exact).expect("exact aggregate bound"); + assert_eq!(measurement.count, 4); + assert_eq!(measurement.bytes, 262_144); + + let over = raw_parameters(&[65_536, 65_536, 65_536, 65_536, 1]); + let error = match measure_parameters(&over) { + Ok(_) => panic!("over aggregate bound must fail"), + Err(error) => error, + }; + assert_eq!( + error.kind(), + MycNip46AdmissionErrorKind::RequestParametersTooLarge + ); + } +} diff --git a/tests/package_boundary.rs b/tests/package_boundary.rs @@ -8,6 +8,7 @@ const PUBLIC_API: &str = include_str!("../contracts/api_baselines/myc.txt"); const SOURCES: &[&str] = &[ include_str!("../src/cli_v1.rs"), include_str!("../src/config_v1.rs"), + include_str!("../src/nip46_admission.rs"), include_str!("../src/provider_contract.rs"), include_str!("../src/provider_credential.rs"), include_str!("../src/provider_envelope.rs"), @@ -37,6 +38,7 @@ fn implementation_modules_are_private_and_rustdoc_uses_the_reviewed_readme() { BTreeSet::from([ "cli_v1", "config_v1", + "nip46_admission", "provider_contract", "provider_credential", "provider_envelope", @@ -77,8 +79,14 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() { "pub struct myc::MycRuntimeFoundation", "pub struct myc::MycRuntimeReadiness", "pub enum myc::MycRuntimeReadinessReason", + "pub struct myc::MycBoundedNip46Event", + "pub struct myc::MycBoundedNip46Request", + "pub struct myc::MycNip46AdmissionLimits", + "pub enum myc::MycNip46AdmissionErrorKind", "pub struct myc::MycStateHost", "pub struct myc::MycStateRepository", + "pub fn myc::admit_myc_nip46_event", + "pub fn myc::admit_myc_nip46_request", "pub async fn myc::open_myc_runtime_foundation", ] { assert!( @@ -90,6 +98,7 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() { for module in [ "cli_v1", "config_v1", + "nip46_admission", "provider_contract", "provider_credential", "provider_envelope", @@ -161,7 +170,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, 19); + assert_eq!(public_error_count, 20); assert!(!PUBLIC_API.contains("pub struct myc::MycRuntimeFoundation {")); assert!(!PUBLIC_API.contains("pub struct myc::MycStateHost {")); } diff --git a/tests/services_hardening_nip46_admission.rs b/tests/services_hardening_nip46_admission.rs @@ -0,0 +1,303 @@ +#![forbid(unsafe_code)] + +use std::error::Error; + +use myc::{ + MYC_NIP46_EVENT_ID_MAX_BYTES, MYC_NIP46_PUBLIC_KEY_MAX_BYTES, MYC_NIP46_SIGNATURE_MAX_BYTES, + MycConfigProfile, MycNip46AdmissionErrorKind, MycNip46AdmissionLimits, admit_myc_nip46_event, + admit_myc_nip46_request, parse_myc_config_v1, +}; +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_admission.v1.json"); + +fn configuration(overrides: &[(&str, usize)]) -> myc::MycConfigDocumentV1 { + let mut source = CONFIG.to_owned(); + for (field, value) in overrides { + let prefix = format!("{field} = "); + let original = source + .lines() + .find(|line| line.starts_with(&prefix)) + .expect("configured event limit") + .to_owned(); + source = source.replacen(&original, &format!("{field} = {value}"), 1); + } + parse_myc_config_v1(source.as_bytes(), MycConfigProfile::RepoLocal).expect("test configuration") +} + +fn limits(overrides: &[(&str, usize)]) -> MycNip46AdmissionLimits { + MycNip46AdmissionLimits::from_config(&configuration(overrides)).expect("admission limits") +} + +fn event(content: &str, tags: Value) -> Vec<u8> { + serde_json::to_vec(&json!({ + "id": "11".repeat(32), + "pubkey": "22".repeat(32), + "created_at": 1_725_000_000_u64, + "kind": 24_133_u64, + "tags": tags, + "content": content, + "sig": "33".repeat(64) + })) + .expect("event JSON") +} + +fn request(id: &str, method: &str, params: Value) -> Vec<u8> { + serde_json::to_vec(&json!({"id": id, "method": method, "params": params})) + .expect("request JSON") +} + +fn event_error(bytes: &[u8], limits: MycNip46AdmissionLimits) -> MycNip46AdmissionErrorKind { + admit_myc_nip46_event(limits, bytes) + .expect_err("event must fail") + .kind() +} + +fn request_error(bytes: &[u8], limits: MycNip46AdmissionLimits) -> MycNip46AdmissionErrorKind { + admit_myc_nip46_request(limits, bytes) + .expect_err("request must fail") + .kind() +} + +#[test] +fn machine_contract_and_config_projection_are_exact() { + let contract: Value = serde_json::from_str(CONTRACT).expect("admission contract JSON"); + assert_eq!(contract["schema"], "radroots.myc.nip46-admission.v1"); + assert_eq!(contract["contract_version"], 1); + assert_eq!(contract["event"]["original_wire_cap_before_parse"], true); + assert_eq!( + contract["event"]["decryption"], + "forbidden_before_admission" + ); + assert_eq!(contract["request"]["typed_decode"], "deferred_to_step_142"); + + let limits = limits(&[]); + assert_eq!(limits.event_wire_bytes(), 262_144); + assert_eq!(limits.event_content_bytes(), 131_072); + assert_eq!(limits.event_tag_count(), 1_024); + assert_eq!(limits.event_tag_total_elements(), 4_096); + assert_eq!(limits.event_tag_element_bytes(), 4_096); + assert_eq!(limits.event_tag_total_bytes(), 131_072); + assert_eq!(limits.decrypted_plaintext_bytes(), 262_144); + assert_eq!(MYC_NIP46_EVENT_ID_MAX_BYTES, 64); + assert_eq!(MYC_NIP46_PUBLIC_KEY_MAX_BYTES, 64); + assert_eq!(MYC_NIP46_SIGNATURE_MAX_BYTES, 128); +} + +#[test] +fn event_admission_retains_exact_bytes_and_bounded_ciphertext_without_semantic_claims() { + let bytes = event( + "ciphertext-secret-marker", + json!([["p", "44".repeat(32)], ["alt", "v"]]), + ); + let admitted = admit_myc_nip46_event(limits(&[]), &bytes).expect("bounded event"); + assert_eq!(admitted.original_bytes(), bytes); + assert_eq!(admitted.encrypted_content(), "ciphertext-secret-marker"); + assert_eq!(admitted.tag_count(), 2); + assert_eq!(admitted.tag_element_count(), 4); + assert_eq!(admitted.tag_bytes(), 1 + 64 + 3 + 1); + + let rendered = format!("{admitted:?}"); + assert!(!rendered.contains("ciphertext-secret-marker")); + assert!(!rendered.contains(&"44".repeat(32))); +} + +#[test] +fn original_event_cap_precedes_utf8_json_and_allocation() { + let cap = 128; + let oversized = vec![0xff; cap + 1]; + assert_eq!( + event_error(&oversized, limits(&[("wire_bytes", cap)])), + MycNip46AdmissionErrorKind::EventTooLarge + ); + assert_eq!( + event_error(&[], limits(&[])), + MycNip46AdmissionErrorKind::EmptyEvent + ); + assert_eq!( + event_error(&[0xff], limits(&[])), + MycNip46AdmissionErrorKind::InvalidEventUtf8 + ); +} + +#[test] +fn event_object_is_closed_duplicate_free_nonnull_and_exactly_consumed() { + let valid = String::from_utf8(event("x", json!([]))).expect("UTF-8 event"); + let duplicate = valid.replacen("\"id\":", "\"id\":\"11\",\"id\":", 1); + let unknown = valid.replacen('{', "{\"extra\":1,", 1); + let null = valid.replacen("\"content\":\"x\"", "\"content\":null", 1); + for wire in [duplicate, unknown, null, format!("{valid} true")] { + assert_eq!( + event_error(wire.as_bytes(), limits(&[])), + MycNip46AdmissionErrorKind::MalformedEvent + ); + } +} + +#[test] +fn event_identifier_and_content_bounds_count_decoded_utf8_bytes() { + let base = String::from_utf8(event("x", json!([]))).expect("event"); + for (field, maximum) in [ + ("id", MYC_NIP46_EVENT_ID_MAX_BYTES), + ("pubkey", MYC_NIP46_PUBLIC_KEY_MAX_BYTES), + ("sig", MYC_NIP46_SIGNATURE_MAX_BYTES), + ] { + let current = if field == "sig" { + "33".repeat(64) + } else if field == "pubkey" { + "22".repeat(32) + } else { + "11".repeat(32) + }; + let oversized = base.replacen( + &format!("\"{field}\":\"{current}\""), + &format!("\"{field}\":\"{}\"", "a".repeat(maximum + 1)), + 1, + ); + assert_eq!( + event_error(oversized.as_bytes(), limits(&[])), + MycNip46AdmissionErrorKind::EventIdentifierTooLarge + ); + } + + let exact = event("éé", json!([])); + assert!(admit_myc_nip46_event(limits(&[("content_bytes", 4)]), &exact).is_ok()); + let over = event("ééa", json!([])); + assert_eq!( + event_error(&over, limits(&[("content_bytes", 4)])), + MycNip46AdmissionErrorKind::EventContentTooLarge + ); + + let escaped_exact = base.replacen("\"content\":\"x\"", "\"content\":\"\\u00e9\\u00e9\"", 1); + assert!( + admit_myc_nip46_event(limits(&[("content_bytes", 4)]), escaped_exact.as_bytes()).is_ok() + ); +} + +#[test] +fn tag_count_element_and_aggregate_bounds_fail_independently() { + let exact_count = event("x", json!([["a"], ["b"]])); + assert!(admit_myc_nip46_event(limits(&[("tag_count", 2)]), &exact_count).is_ok()); + let over_count = event("x", json!([["a"], ["b"], ["c"]])); + assert_eq!( + event_error(&over_count, limits(&[("tag_count", 2)])), + MycNip46AdmissionErrorKind::TooManyTags + ); + + let exact_elements = event("x", json!([["a", "b"], ["c"]])); + assert!(admit_myc_nip46_event(limits(&[("tag_total_elements", 3)]), &exact_elements).is_ok()); + let over_elements = event("x", json!([["a", "b"], ["c", "d"]])); + assert_eq!( + event_error(&over_elements, limits(&[("tag_total_elements", 3)])), + MycNip46AdmissionErrorKind::TooManyTagElements + ); + + let exact_element = event("x", json!([["éé"]])); + assert!(admit_myc_nip46_event(limits(&[("tag_element_bytes", 4)]), &exact_element).is_ok()); + let over_element = event("x", json!([["ééa"]])); + assert_eq!( + event_error(&over_element, limits(&[("tag_element_bytes", 4)])), + MycNip46AdmissionErrorKind::TagElementTooLarge + ); + + let exact_total = event("x", json!([["ab"], ["cd"]])); + assert!(admit_myc_nip46_event(limits(&[("tag_total_bytes", 4)]), &exact_total).is_ok()); + let over_total = event("x", json!([["ab"], ["cde"]])); + assert_eq!( + event_error(&over_total, limits(&[("tag_total_bytes", 4)])), + MycNip46AdmissionErrorKind::TagsTooLarge + ); + + for malformed in [event("x", json!(["not-a-tag"])), event("x", json!([[1]]))] { + assert_eq!( + event_error(&malformed, limits(&[])), + MycNip46AdmissionErrorKind::MalformedEvent + ); + } +} + +#[test] +fn request_plaintext_cap_precedes_utf8_json_and_content_exposure() { + let cap = 128; + let oversized = vec![0xff; cap + 1]; + assert_eq!( + request_error(&oversized, limits(&[("decrypted_plaintext_bytes", cap)])), + MycNip46AdmissionErrorKind::PlaintextTooLarge + ); + assert_eq!( + request_error(&[], limits(&[])), + MycNip46AdmissionErrorKind::EmptyPlaintext + ); + assert_eq!( + request_error(&[0xff], limits(&[])), + MycNip46AdmissionErrorKind::InvalidPlaintextUtf8 + ); + + let bytes = request("request-secret-marker", "ping", json!([])); + let admitted = admit_myc_nip46_request(limits(&[]), &bytes).expect("bounded request"); + assert_eq!(admitted.plaintext_bytes(), bytes.len()); + assert_eq!(admitted.request_id_bytes(), 21); + assert_eq!(admitted.method_bytes(), 4); + assert_eq!(admitted.parameter_count(), 0); + let rendered = format!("{admitted:?}"); + assert!(!rendered.contains("request-secret-marker")); + assert!(!rendered.contains("ping")); +} + +#[test] +fn request_object_identifiers_and_parameters_are_strict_and_bounded() { + let valid = String::from_utf8(request("id", "ping", json!([]))).expect("request"); + let duplicate = valid.replacen("\"id\":", "\"id\":\"other\",\"id\":", 1); + let unknown = valid.replacen('{', "{\"extra\":1,", 1); + let null = valid.replacen("\"params\":[]", "\"params\":null", 1); + for wire in [duplicate, unknown, null, format!("{valid} false")] { + assert_eq!( + request_error(wire.as_bytes(), limits(&[])), + MycNip46AdmissionErrorKind::MalformedRequest + ); + } + + assert_eq!( + request_error(&request(&"i".repeat(129), "ping", json!([])), limits(&[])), + MycNip46AdmissionErrorKind::RequestIdentifierTooLarge + ); + assert_eq!( + request_error(&request("id", &"m".repeat(65), json!([])), limits(&[])), + MycNip46AdmissionErrorKind::RequestMethodTooLarge + ); + + let exact_count = request("id", "custom", json!(vec!["x"; 64])); + assert!(admit_myc_nip46_request(limits(&[]), &exact_count).is_ok()); + let over_count = request("id", "custom", json!(vec!["x"; 65])); + assert_eq!( + request_error(&over_count, limits(&[])), + MycNip46AdmissionErrorKind::TooManyRequestParameters + ); + + let exact_parameter = request("id", "custom", json!(["x".repeat(65_536)])); + assert!(admit_myc_nip46_request(limits(&[]), &exact_parameter).is_ok()); + let over_parameter = request("id", "custom", json!(["x".repeat(65_537)])); + assert_eq!( + request_error(&over_parameter, limits(&[])), + MycNip46AdmissionErrorKind::RequestParameterTooLarge + ); + + assert_eq!( + request_error(&request("id", "custom", json!([1])), limits(&[])), + MycNip46AdmissionErrorKind::MalformedRequest + ); +} + +#[test] +fn public_errors_are_source_free_and_diagnostics_never_render_input() { + let marker = "protected-plaintext-marker"; + let bytes = request(marker, "ping", json!([])); + let error = admit_myc_nip46_request(limits(&[("decrypted_plaintext_bytes", 1)]), &bytes) + .expect_err("oversized plaintext"); + assert_eq!(error.kind(), MycNip46AdmissionErrorKind::PlaintextTooLarge); + assert!(error.source().is_none()); + let rendered = format!("{error} {error:?}"); + assert!(!rendered.contains(marker)); + assert!(!rendered.contains(&String::from_utf8_lossy(&bytes).into_owned())); +}