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:
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]\" }"
+ );
+}