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