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