myc

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

commit 47dca4d428fd11712a30a7b8a81de181b391910d
parent 151cf783ffc7b0a287bd14824279bdde00c230aa
Author: triesap <tyson@radroots.org>
Date:   Sat, 22 Aug 2026 00:33:26 +0000

feat(myc): bind NIP-46 replay identity

Diffstat:
Mcontracts/api_baselines/myc.txt | 32++++++++++++++++++++++++++++++++
Acontracts/services_hardening/nip46_replay.v1.json | 45+++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 5+++++
Asrc/nip46_replay.rs | 210+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/nip46_verification.rs | 12+++++++++++-
Msrc/state_request.rs | 2+-
Mtests/package_boundary.rs | 44++++++++++++++++++++++++++++++++++++++++++++
Atests/services_hardening_nip46_replay.rs | 294+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 642 insertions(+), 2 deletions(-)

diff --git a/contracts/api_baselines/myc.txt b/contracts/api_baselines/myc.txt @@ -302,6 +302,12 @@ pub myc::MycNip46AdmissionErrorKind::TooManyTags pub enum myc::MycNip46EncryptionContext pub myc::MycNip46EncryptionContext::Nip04 pub myc::MycNip46EncryptionContext::Nip44V2 +pub enum myc::MycNip46ReplayDisposition +pub myc::MycNip46ReplayDisposition::ConflictingEventReuse +pub myc::MycNip46ReplayDisposition::ConflictingRequestReuse +pub myc::MycNip46ReplayDisposition::DistinctRequest +pub myc::MycNip46ReplayDisposition::DuplicateEvent +pub myc::MycNip46ReplayDisposition::ExactRequestReplay pub enum myc::MycNip46VerificationErrorKind pub myc::MycNip46VerificationErrorKind::AuthoredTimeRejected pub myc::MycNip46VerificationErrorKind::InvalidEventId @@ -937,6 +943,11 @@ pub fn myc::MycNip46ClientPublicKey::as_hex(&self) -> &str pub fn myc::MycNip46ClientPublicKey::new(&str) -> core::result::Result<Self, myc::MycSignerRequestError> impl core::fmt::Debug for myc::MycNip46ClientPublicKey pub fn myc::MycNip46ClientPublicKey::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycNip46ConnectionIdentity(_) +impl myc::MycNip46ConnectionIdentity +pub const fn myc::MycNip46ConnectionIdentity::as_bytes(&self) -> &[u8; 32] +impl core::fmt::Debug for myc::MycNip46ConnectionIdentity +pub fn myc::MycNip46ConnectionIdentity::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct myc::MycNip46EventId(_) impl myc::MycNip46EventId pub const fn myc::MycNip46EventId::as_bytes(&self) -> &[u8; 32] @@ -944,10 +955,18 @@ 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::MycNip46LogicalRequestIdentity(_) +impl myc::MycNip46LogicalRequestIdentity +pub const fn myc::MycNip46LogicalRequestIdentity::as_bytes(&self) -> &[u8; 32] +impl core::fmt::Debug for myc::MycNip46LogicalRequestIdentity +pub fn myc::MycNip46LogicalRequestIdentity::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::MycNip46ReplayKey +impl core::fmt::Debug for myc::MycNip46ReplayKey +pub fn myc::MycNip46ReplayKey::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct myc::MycNip46RequestId(_) impl myc::MycNip46RequestId pub fn myc::MycNip46RequestId::as_str(&self) -> &str @@ -1082,6 +1101,18 @@ impl myc::MycRateRelayId pub fn myc::MycRateRelayId::new(&str) -> core::result::Result<Self, myc::MycGovernanceStateError> impl core::fmt::Debug for myc::MycRateRelayId pub fn myc::MycRateRelayId::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycReplayBoundNip46Request +impl myc::MycReplayBoundNip46Request +pub fn myc::MycReplayBoundNip46Request::classify_against(&self, myc::MycNip46ReplayKey) -> myc::MycNip46ReplayDisposition +pub const fn myc::MycReplayBoundNip46Request::client_public_key(&self) -> &myc::MycNip46ClientPublicKey +pub const fn myc::MycReplayBoundNip46Request::connection_identity(&self) -> myc::MycNip46ConnectionIdentity +pub const fn myc::MycReplayBoundNip46Request::event_id(&self) -> myc::MycNip46EventId +pub const fn myc::MycReplayBoundNip46Request::replay_key(&self) -> myc::MycNip46ReplayKey +pub const fn myc::MycReplayBoundNip46Request::request_digest(&self) -> myc::MycSignerRequestDigest +pub const fn myc::MycReplayBoundNip46Request::request_id(&self) -> &myc::MycNip46RequestId +pub const fn myc::MycReplayBoundNip46Request::request_identity(&self) -> myc::MycNip46LogicalRequestIdentity +impl core::fmt::Debug for myc::MycReplayBoundNip46Request +pub fn myc::MycReplayBoundNip46Request::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct myc::MycRequestReceivedAtUnixMs(_) impl myc::MycRequestReceivedAtUnixMs pub const fn myc::MycRequestReceivedAtUnixMs::get(self) -> u64 @@ -1392,6 +1423,7 @@ 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 fn myc::bind_myc_nip46_replay(myc::MycVerifiedNip46Event, myc::MycVerifiedNip46Request) -> core::result::Result<myc::MycReplayBoundNip46Request, myc::MycSignerRequestError> 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_replay.v1.json b/contracts/services_hardening/nip46_replay.v1.json @@ -0,0 +1,45 @@ +{ + "schema": "radroots.myc.nip46-replay.v1", + "contract_version": 1, + "prerequisite": "nip46_verification.v1", + "connection_identity": { + "domain": "radroots.myc.nip46.connection_identity.v1\\0", + "preimage": "domain_then_u64_be_receiver_public_key_byte_length_and_exact_lowercase_ascii_receiver_then_u64_be_client_public_key_byte_length_and_exact_lowercase_ascii_client", + "digest": "sha256", + "stability": "same_configured_transport_receiver_and_verified_client_identity_same_connection_identity" + }, + "request_identity": { + "domain": "radroots.myc.nip46.request_identity.v1\\0", + "preimage": "domain_then_u64_be_client_public_key_byte_length_and_exact_lowercase_ascii_public_key_then_u64_be_request_id_byte_length_and_exact_request_id", + "digest": "sha256", + "scope": "catalog_local_verified_client_transport_identity_and_validated_request_id_compared_only_with_matching_connection_identity" + }, + "request_digest": { + "domain": "radroots.myc.nip46.request.v1\\0", + "preimage": "domain_then_u64_be_canonical_typed_request_byte_length_then_exact_canonical_typed_request_bytes", + "digest": "sha256", + "plaintext_retention": "private_sealed_capability_only" + }, + "retained_replay_key": "copyable_redacted_event_connection_request_and_content_digests_only_no_event_ciphertext_or_plaintext", + "classification_precedence": [ + "same_event_connection_request_identity_and_digest_is_duplicate_event", + "same_event_with_other_request_identity_or_digest_is_conflicting_event_reuse", + "distinct_event_and_same_connection_request_identity_and_digest_is_exact_request_replay", + "distinct_event_and_same_connection_request_identity_with_other_digest_is_conflicting_request_reuse", + "distinct_connection_or_request_identity_is_distinct_request" + ], + "durable_authority": { + "owner": "existing_myc_state_repository_dual_request_and_event_dedup", + "operation_identity": "first_durable_admission_uses_injected_entropy_and_exact_replay_returns_original_operation_and_correlation", + "new_replay_store": "forbidden" + }, + "nonclaims": [ + "ciphertext_decryption", + "plaintext_event_cryptographic_binding", + "supported_method_admission", + "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_replay; mod nip46_verification; mod provider_contract; mod provider_credential; @@ -38,6 +39,10 @@ pub use nip46_admission::{ MycNip46AdmissionErrorKind, MycNip46AdmissionLimits, admit_myc_nip46_event, admit_myc_nip46_request, }; +pub use nip46_replay::{ + MycNip46ConnectionIdentity, MycNip46LogicalRequestIdentity, MycNip46ReplayDisposition, + MycNip46ReplayKey, MycReplayBoundNip46Request, bind_myc_nip46_replay, +}; pub use nip46_verification::{ MycNip46AuthoredTimePolicy, MycNip46EncryptionContext, MycNip46ObservedAtUnixSeconds, MycNip46VerificationError, MycNip46VerificationErrorKind, MycVerifiedNip46Event, diff --git a/src/nip46_replay.rs b/src/nip46_replay.rs @@ -0,0 +1,210 @@ +//! Stable replay identities derived from verified NIP-46 input. + +use core::fmt; + +use sha2::{Digest, Sha256}; + +use crate::{ + MycNip46ClientPublicKey, MycNip46EventId, MycNip46RequestId, MycProviderPublicIdentity, + MycSignerRequestDigest, MycSignerRequestError, MycVerifiedNip46Event, MycVerifiedNip46Request, + state_request::derive_request_identity, +}; + +const CONNECTION_IDENTITY_DOMAIN: &[u8] = b"radroots.myc.nip46.connection_identity.v1\0"; + +macro_rules! redacted_identity { + ($name:ident) => { + #[derive(Clone, Copy, PartialEq, Eq, Hash)] + pub struct $name([u8; 32]); + + impl $name { + /// Returns the exact stable identity bytes. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + } + + impl fmt::Debug for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(concat!(stringify!($name), "([redacted])")) + } + } + }; +} + +redacted_identity!(MycNip46ConnectionIdentity); +redacted_identity!(MycNip46LogicalRequestIdentity); + +/// Pure relation between one retained replay binding and a new candidate. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycNip46ReplayDisposition { + /// The event and logical request identities are both new. + DistinctRequest, + /// The exact signed event and exact canonical request were already observed. + DuplicateEvent, + /// A distinct signed event carries the exact same logical request. + ExactRequestReplay, + /// One logical request identity was reused with different canonical bytes. + ConflictingRequestReuse, + /// One signed event identity was paired with a different logical request. + ConflictingEventReuse, +} + +/// Compact replay identity that retains no event, ciphertext, or plaintext. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct MycNip46ReplayKey { + event_id: MycNip46EventId, + connection_identity: MycNip46ConnectionIdentity, + request_identity: MycNip46LogicalRequestIdentity, + request_digest: MycSignerRequestDigest, +} + +impl fmt::Debug for MycNip46ReplayKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MycNip46ReplayKey([redacted])") + } +} + +/// Sealed replay evidence derived from one verified event/request candidate. +/// +/// This type does not prove that the plaintext was decrypted from the retained +/// event. The Step 145 decryption boundary owns that cryptographic association +/// before this evidence may be admitted to durable request state. +pub struct MycReplayBoundNip46Request { + event: MycVerifiedNip46Event, + request: MycVerifiedNip46Request, + connection_identity: MycNip46ConnectionIdentity, + request_identity: MycNip46LogicalRequestIdentity, + request_digest: MycSignerRequestDigest, +} + +impl MycReplayBoundNip46Request { + /// Returns the stable client connection identity. + #[must_use] + pub const fn connection_identity(&self) -> MycNip46ConnectionIdentity { + self.connection_identity + } + + /// Returns the stable connection-scoped request identity. + #[must_use] + pub const fn request_identity(&self) -> MycNip46LogicalRequestIdentity { + self.request_identity + } + + /// Returns the digest of the exact canonical typed request bytes. + #[must_use] + pub const fn request_digest(&self) -> MycSignerRequestDigest { + self.request_digest + } + + /// Returns the cryptographically verified signed-event identity. + #[must_use] + pub const fn event_id(&self) -> MycNip46EventId { + self.event.event_id() + } + + /// Returns the verified client transport identity. + #[must_use] + pub const fn client_public_key(&self) -> &MycNip46ClientPublicKey { + self.event.client_public_key() + } + + /// Returns the validated request identifier. + #[must_use] + pub const fn request_id(&self) -> &MycNip46RequestId { + self.request.request_id() + } + + /// Projects the digest-only replay key without retaining protected content. + #[must_use] + pub const fn replay_key(&self) -> MycNip46ReplayKey { + MycNip46ReplayKey { + event_id: self.event.event_id(), + connection_identity: self.connection_identity, + request_identity: self.request_identity, + request_digest: self.request_digest, + } + } + + /// Classifies this candidate against one retained digest-only key. + #[must_use] + pub fn classify_against(&self, retained: MycNip46ReplayKey) -> MycNip46ReplayDisposition { + let same_event = self.event_id() == retained.event_id; + let same_connection = self.connection_identity == retained.connection_identity; + let same_request_identity = self.request_identity == retained.request_identity; + let same_request_bytes = self.request_digest == retained.request_digest; + + if same_event { + if same_connection && same_request_identity && same_request_bytes { + MycNip46ReplayDisposition::DuplicateEvent + } else { + MycNip46ReplayDisposition::ConflictingEventReuse + } + } else if same_connection && same_request_identity { + if same_request_bytes { + MycNip46ReplayDisposition::ExactRequestReplay + } else { + MycNip46ReplayDisposition::ConflictingRequestReuse + } + } else { + MycNip46ReplayDisposition::DistinctRequest + } + } +} + +impl fmt::Debug for MycReplayBoundNip46Request { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycReplayBoundNip46Request") + .field("identity", &"[redacted]") + .finish() + } +} + +/// Derives stable replay identities from one verified event/request candidate. +/// +/// The caller must not treat this pure identity projection as proof that the +/// request plaintext came from the encrypted event. Step 145 owns decryption, +/// association, supported-method admission, and conversion into durable work. +pub fn bind_myc_nip46_replay( + event: MycVerifiedNip46Event, + request: MycVerifiedNip46Request, +) -> Result<MycReplayBoundNip46Request, MycSignerRequestError> { + let connection_identity = + derive_connection_identity(event.receiver_public_key(), event.client_public_key()); + let request_identity = MycNip46LogicalRequestIdentity(derive_request_identity( + event.client_public_key(), + request.request_id(), + )); + let request_digest = + MycSignerRequestDigest::for_canonical_request(request.canonical_request())?; + + Ok(MycReplayBoundNip46Request { + event, + request, + connection_identity, + request_identity, + request_digest, + }) +} + +fn derive_connection_identity( + receiver_public_key: &MycProviderPublicIdentity, + client_public_key: &MycNip46ClientPublicKey, +) -> MycNip46ConnectionIdentity { + let mut hasher = Sha256::new(); + hasher.update(CONNECTION_IDENTITY_DOMAIN); + update_length_prefixed(&mut hasher, receiver_public_key.as_hex().as_bytes()); + update_length_prefixed(&mut hasher, client_public_key.as_hex().as_bytes()); + MycNip46ConnectionIdentity(hasher.finalize().into()) +} + +fn update_length_prefixed(hasher: &mut Sha256, value: &[u8]) { + hasher.update( + u64::try_from(value.len()) + .expect("canonical identity length fits u64") + .to_be_bytes(), + ); + hasher.update(value); +} diff --git a/src/nip46_verification.rs b/src/nip46_verification.rs @@ -13,7 +13,7 @@ use serde::Deserialize; use crate::{ MycBoundedNip46Event, MycBoundedNip46Request, MycNip46ClientPublicKey, MycNip46EventId, - MycNip46RequestId, MycProviderBinding, MycProviderRole, + MycNip46RequestId, MycProviderBinding, MycProviderPublicIdentity, MycProviderRole, }; const NIP04_IV_BYTES: usize = 16; @@ -170,6 +170,7 @@ pub struct MycVerifiedNip46Event { encrypted_content: Box<str>, event_id: MycNip46EventId, client_public_key: MycNip46ClientPublicKey, + receiver_public_key: MycProviderPublicIdentity, authored_at_unix_seconds: u64, encryption_context: MycNip46EncryptionContext, } @@ -193,6 +194,10 @@ impl MycVerifiedNip46Event { &self.client_public_key } + pub(crate) const fn receiver_public_key(&self) -> &MycProviderPublicIdentity { + &self.receiver_public_key + } + /// Returns the verified event authored time. #[must_use] pub const fn authored_at_unix_seconds(&self) -> u64 { @@ -258,6 +263,10 @@ impl MycVerifiedNip46Request { pub const fn parameter_bytes(&self) -> usize { self.parameter_bytes } + + pub(crate) fn canonical_request(&self) -> &[u8] { + &self.canonical_request + } } impl fmt::Debug for MycVerifiedNip46Request { @@ -349,6 +358,7 @@ pub fn verify_myc_nip46_event( encrypted_content, event_id: MycNip46EventId::from_bytes(event.id.to_bytes()), client_public_key, + receiver_public_key: expected_receiver.clone(), authored_at_unix_seconds: event.created_at.as_secs(), encryption_context, }) diff --git a/src/state_request.rs b/src/state_request.rs @@ -997,7 +997,7 @@ fn bounded_count( u64::try_from(value).map_err(|_| RequestOperationError::Binding) } -fn derive_request_identity( +pub(crate) fn derive_request_identity( client_public_key: &MycNip46ClientPublicKey, request_id: &MycNip46RequestId, ) -> [u8; 32] { diff --git a/tests/package_boundary.rs b/tests/package_boundary.rs @@ -6,12 +6,16 @@ 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_REPLAY: &str = include_str!("../src/nip46_replay.rs"); const NIP46_VERIFICATION_CONTRACT: &str = include_str!("../contracts/services_hardening/nip46_verification.v1.json"); +const NIP46_REPLAY_CONTRACT: &str = + include_str!("../contracts/services_hardening/nip46_replay.v1.json"); const SOURCES: &[&str] = &[ include_str!("../src/cli_v1.rs"), include_str!("../src/config_v1.rs"), include_str!("../src/nip46_admission.rs"), + include_str!("../src/nip46_replay.rs"), include_str!("../src/nip46_verification.rs"), include_str!("../src/provider_contract.rs"), include_str!("../src/provider_credential.rs"), @@ -43,6 +47,7 @@ fn implementation_modules_are_private_and_rustdoc_uses_the_reviewed_readme() { "cli_v1", "config_v1", "nip46_admission", + "nip46_replay", "nip46_verification", "provider_contract", "provider_credential", @@ -91,11 +96,17 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() { "pub struct myc::MycVerifiedNip46Event", "pub struct myc::MycVerifiedNip46Request", "pub struct myc::MycNip46AuthoredTimePolicy", + "pub struct myc::MycNip46ConnectionIdentity", + "pub struct myc::MycNip46LogicalRequestIdentity", + "pub struct myc::MycNip46ReplayKey", + "pub struct myc::MycReplayBoundNip46Request", + "pub enum myc::MycNip46ReplayDisposition", "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::bind_myc_nip46_replay", "pub fn myc::verify_myc_nip46_event", "pub fn myc::verify_myc_nip46_request", "pub async fn myc::open_myc_runtime_foundation", @@ -110,6 +121,7 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() { "cli_v1", "config_v1", "nip46_admission", + "nip46_replay", "nip46_verification", "provider_contract", "provider_credential", @@ -161,6 +173,38 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() { } #[test] +fn step143_replay_binding_remains_pure_and_reuses_the_durable_authority() { + for forbidden in [ + "ServiceSqlite", + "sqlx::", + "tokio::spawn", + "std::time::SystemTime", + "rand::", + "getrandom", + "RelayPool", + "nip04::decrypt", + "nip44::decrypt", + ] { + assert!( + !NIP46_REPLAY.contains(forbidden), + "Step 143 gained forbidden authority `{forbidden}`" + ); + } + for required in [ + "existing_myc_state_repository_dual_request_and_event_dedup", + "\"new_replay_store\": \"forbidden\"", + "\"plaintext_event_cryptographic_binding\"", + "\"supported_method_admission\"", + "\"database_mutation\"", + ] { + assert!( + NIP46_REPLAY_CONTRACT.contains(required), + "Step 143 contract is missing `{required}`" + ); + } +} + +#[test] fn public_errors_remain_crate_owned_redacted_and_source_free() { let production = SOURCES.join("\n"); diff --git a/tests/services_hardening_nip46_replay.rs b/tests/services_hardening_nip46_replay.rs @@ -0,0 +1,294 @@ +#![forbid(unsafe_code)] + +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use myc::{ + MycConfigProfile, MycNip46AdmissionLimits, MycNip46AuthoredTimePolicy, + MycNip46ObservedAtUnixSeconds, MycNip46ReplayDisposition, MycProviderBinding, MycProviderRole, + admit_myc_nip46_event, admit_myc_nip46_request, bind_myc_nip46_replay, parse_myc_config_v1, + verify_myc_nip46_event, verify_myc_nip46_request, +}; +use nostr::{Event, EventBuilder, Keys, Kind, Tag, Timestamp}; +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_replay.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(target: &Keys) -> MycProviderBinding { + let section_start = CONFIG + .find("[identity.transport]") + .expect("transport section"); + let original = "4444444444444444444444444444444444444444444444444444444444444444"; + 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(), + &target.public_key().to_hex(), + ); + parse_myc_config_v1(source.as_bytes(), MycConfigProfile::RepoLocal) + .expect("test config") + .provider_contract() + .binding(MycProviderRole::Transport) + .expect("transport binding") + .clone() +} + +fn event(sender: &Keys, target: &Keys, created_at: u64) -> Event { + let content = BASE64_STANDARD.encode([vec![2], vec![0; 32], vec![0; 34], vec![0; 32]].concat()); + EventBuilder::new(Kind::Custom(24_133), content) + .tag(Tag::public_key(target.public_key())) + .custom_created_at(Timestamp::from_secs(created_at)) + .sign_with_keys(sender) + .expect("signed event") +} + +fn candidate( + sender: &Keys, + target: &Keys, + created_at: u64, + request_id: &str, + method: &str, + params: Value, +) -> myc::MycReplayBoundNip46Request { + let plaintext = serde_json::to_vec(&json!({ + "id": request_id, + "method": method, + "params": params + })) + .expect("request wire"); + candidate_from_plaintext(sender, target, created_at, &plaintext) +} + +fn candidate_from_plaintext( + sender: &Keys, + target: &Keys, + created_at: u64, + plaintext: &[u8], +) -> myc::MycReplayBoundNip46Request { + let event = event(sender, target, created_at); + let wire = serde_json::to_vec(&event).expect("event wire"); + let event = verify_myc_nip46_event( + admit_myc_nip46_event(limits(), &wire).expect("bounded event"), + &provider_binding(target), + MycNip46ObservedAtUnixSeconds::new(OBSERVED_AT).expect("observed"), + MycNip46AuthoredTimePolicy::new(60, 60).expect("policy"), + ) + .expect("verified event"); + let request = verify_myc_nip46_request( + admit_myc_nip46_request(limits(), plaintext).expect("bounded request"), + ) + .expect("verified request"); + bind_myc_nip46_replay(event, request).expect("replay binding") +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +#[test] +fn machine_contract_freezes_identity_framing_and_authority_split() { + let contract: Value = serde_json::from_str(CONTRACT).expect("replay contract"); + assert_eq!(contract["schema"], "radroots.myc.nip46-replay.v1"); + assert_eq!(contract["contract_version"], 1); + assert_eq!(contract["prerequisite"], "nip46_verification.v1"); + assert_eq!( + contract["connection_identity"]["domain"], + "radroots.myc.nip46.connection_identity.v1\\0" + ); + assert_eq!( + contract["request_identity"]["domain"], + "radroots.myc.nip46.request_identity.v1\\0" + ); + assert_eq!( + contract["request_digest"]["domain"], + "radroots.myc.nip46.request.v1\\0" + ); + assert_eq!(contract["connection_identity"]["digest"], "sha256"); + assert_eq!(contract["request_identity"]["digest"], "sha256"); + assert_eq!(contract["request_digest"]["digest"], "sha256"); + assert_eq!( + contract["durable_authority"]["new_replay_store"], + "forbidden" + ); + assert_eq!( + contract["nonclaims"], + json!([ + "ciphertext_decryption", + "plaintext_event_cryptographic_binding", + "supported_method_admission", + "authorization", + "provider_execution", + "database_mutation", + "relay_publication" + ]) + ); +} + +#[test] +fn stable_connection_request_and_content_vectors_are_exact() { + let bound = candidate( + &keys(1), + &keys(2), + OBSERVED_AT, + "request-01", + "ping", + json!([]), + ); + assert_eq!( + hex(bound.connection_identity().as_bytes()), + "93f2c20f3c8043fdae2ec14f08e398ef5012fd813868285d3ed5c2c98ac8e1c1" + ); + assert_eq!( + hex(bound.request_identity().as_bytes()), + "68efc9cd254207260f097997205ee3e34228b290315c53c92cba054dce0452d9" + ); + assert_eq!( + hex(bound.request_digest().as_bytes()), + "378d4f7906aed41e5af96c7000dfc57d9c4c4c9ad3d94b84e985621c25e727f2" + ); +} + +#[test] +fn duplicate_replay_conflict_and_distinct_relations_are_total() { + let sender = keys(3); + let target = keys(4); + let retained = candidate( + &sender, + &target, + OBSERVED_AT, + "request-01", + "ping", + json!([]), + ); + let retained_key = retained.replay_key(); + assert_eq!(format!("{retained_key:?}"), "MycNip46ReplayKey([redacted])"); + drop(retained); + let duplicate = candidate( + &sender, + &target, + OBSERVED_AT, + "request-01", + "ping", + json!([]), + ); + assert_eq!( + duplicate.classify_against(retained_key), + MycNip46ReplayDisposition::DuplicateEvent + ); + + let replay = candidate( + &sender, + &target, + OBSERVED_AT + 1, + "request-01", + "ping", + json!([]), + ); + assert_eq!( + replay.classify_against(retained_key), + MycNip46ReplayDisposition::ExactRequestReplay + ); + + let normalized_replay = candidate_from_plaintext( + &sender, + &target, + OBSERVED_AT + 1, + br#"{ "params" : [ ], "method" : "ping", "id" : "request-01" }"#, + ); + assert_eq!( + normalized_replay.classify_against(retained_key), + MycNip46ReplayDisposition::ExactRequestReplay + ); + + let request_conflict = candidate( + &sender, + &target, + OBSERVED_AT + 2, + "request-01", + "vendor_method", + json!(["changed"]), + ); + assert_eq!( + request_conflict.classify_against(retained_key), + MycNip46ReplayDisposition::ConflictingRequestReuse + ); + + let event_conflict = candidate( + &sender, + &target, + OBSERVED_AT, + "request-02", + "ping", + json!([]), + ); + assert_eq!( + event_conflict.classify_against(retained_key), + MycNip46ReplayDisposition::ConflictingEventReuse + ); + + let distinct = candidate( + &keys(5), + &target, + OBSERVED_AT + 3, + "request-01", + "ping", + json!([]), + ); + assert_eq!( + distinct.classify_against(retained_key), + MycNip46ReplayDisposition::DistinctRequest + ); + + let other_receiver = candidate( + &sender, + &keys(8), + OBSERVED_AT + 4, + "request-01", + "ping", + json!([]), + ); + assert_eq!( + other_receiver.classify_against(retained_key), + MycNip46ReplayDisposition::DistinctRequest + ); +} + +#[test] +fn binding_is_sealed_redacted_and_not_execution_authority() { + let sender = keys(6); + let target = keys(7); + let bound = candidate( + &sender, + &target, + OBSERVED_AT, + "protected-request-marker", + "vendor_secret_method", + json!(["protected-parameter-marker"]), + ); + let rendered = format!("{bound:?}"); + for secret in [ + "protected-request-marker", + "vendor_secret_method", + "protected-parameter-marker", + &sender.public_key().to_hex(), + ] { + assert!(!rendered.contains(secret)); + } + assert_eq!( + rendered, + "MycReplayBoundNip46Request { identity: \"[redacted]\" }" + ); +}