myc

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

nip46_replay.rs (7892B)


      1 //! Stable replay identities derived from verified NIP-46 input.
      2 
      3 use core::fmt;
      4 
      5 use sha2::{Digest, Sha256};
      6 
      7 use crate::{
      8     MycNip46ClientPublicKey, MycNip46EventId, MycNip46RequestId, MycProviderPublicIdentity,
      9     MycSignerRequestDigest, MycSignerRequestError, MycVerifiedNip46Event, MycVerifiedNip46Request,
     10     state_request::derive_request_identity,
     11 };
     12 
     13 const CONNECTION_IDENTITY_DOMAIN: &[u8] = b"radroots.myc.nip46.connection_identity.v1\0";
     14 
     15 macro_rules! redacted_identity {
     16     ($name:ident) => {
     17         #[derive(Clone, Copy, PartialEq, Eq, Hash)]
     18         pub struct $name([u8; 32]);
     19 
     20         impl $name {
     21             /// Returns the exact stable identity bytes.
     22             #[must_use]
     23             pub const fn as_bytes(&self) -> &[u8; 32] {
     24                 &self.0
     25             }
     26         }
     27 
     28         impl fmt::Debug for $name {
     29             fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     30                 formatter.write_str(concat!(stringify!($name), "([redacted])"))
     31             }
     32         }
     33     };
     34 }
     35 
     36 redacted_identity!(MycNip46ConnectionIdentity);
     37 redacted_identity!(MycNip46LogicalRequestIdentity);
     38 
     39 /// Pure relation between one retained replay binding and a new candidate.
     40 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
     41 pub enum MycNip46ReplayDisposition {
     42     /// The event and logical request identities are both new.
     43     DistinctRequest,
     44     /// The exact signed event and exact canonical request were already observed.
     45     DuplicateEvent,
     46     /// A distinct signed event carries the exact same logical request.
     47     ExactRequestReplay,
     48     /// One logical request identity was reused with different canonical bytes.
     49     ConflictingRequestReuse,
     50     /// One signed event identity was paired with a different logical request.
     51     ConflictingEventReuse,
     52 }
     53 
     54 /// Compact replay identity that retains no event, ciphertext, or plaintext.
     55 #[derive(Clone, Copy, PartialEq, Eq, Hash)]
     56 pub struct MycNip46ReplayKey {
     57     event_id: MycNip46EventId,
     58     connection_identity: MycNip46ConnectionIdentity,
     59     request_identity: MycNip46LogicalRequestIdentity,
     60     request_digest: MycSignerRequestDigest,
     61 }
     62 
     63 impl fmt::Debug for MycNip46ReplayKey {
     64     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     65         formatter.write_str("MycNip46ReplayKey([redacted])")
     66     }
     67 }
     68 
     69 /// Sealed replay evidence derived from one verified event/request candidate.
     70 ///
     71 /// This type does not prove that the plaintext was decrypted from the retained
     72 /// event. The Step 145 decryption boundary owns that cryptographic association
     73 /// before this evidence may be admitted to durable request state.
     74 pub struct MycReplayBoundNip46Request {
     75     event: MycVerifiedNip46Event,
     76     request: MycVerifiedNip46Request,
     77     connection_identity: MycNip46ConnectionIdentity,
     78     request_identity: MycNip46LogicalRequestIdentity,
     79     request_digest: MycSignerRequestDigest,
     80 }
     81 
     82 impl MycReplayBoundNip46Request {
     83     /// Returns the stable client connection identity.
     84     #[must_use]
     85     pub const fn connection_identity(&self) -> MycNip46ConnectionIdentity {
     86         self.connection_identity
     87     }
     88 
     89     /// Returns the stable connection-scoped request identity.
     90     #[must_use]
     91     pub const fn request_identity(&self) -> MycNip46LogicalRequestIdentity {
     92         self.request_identity
     93     }
     94 
     95     /// Returns the digest of the exact canonical typed request bytes.
     96     #[must_use]
     97     pub const fn request_digest(&self) -> MycSignerRequestDigest {
     98         self.request_digest
     99     }
    100 
    101     /// Returns the cryptographically verified signed-event identity.
    102     #[must_use]
    103     pub const fn event_id(&self) -> MycNip46EventId {
    104         self.event.event_id()
    105     }
    106 
    107     /// Returns the verified client transport identity.
    108     #[must_use]
    109     pub const fn client_public_key(&self) -> &MycNip46ClientPublicKey {
    110         self.event.client_public_key()
    111     }
    112 
    113     #[must_use]
    114     pub(crate) const fn encryption_context(&self) -> crate::MycNip46EncryptionContext {
    115         self.event.encryption_context()
    116     }
    117 
    118     /// Returns the validated request identifier.
    119     #[must_use]
    120     pub const fn request_id(&self) -> &MycNip46RequestId {
    121         self.request.request_id()
    122     }
    123 
    124     pub(crate) fn method(&self) -> &str {
    125         self.request.method()
    126     }
    127 
    128     pub(crate) fn canonical_request(&self) -> &[u8] {
    129         self.request.canonical_request()
    130     }
    131 
    132     /// Projects the digest-only replay key without retaining protected content.
    133     #[must_use]
    134     pub const fn replay_key(&self) -> MycNip46ReplayKey {
    135         MycNip46ReplayKey {
    136             event_id: self.event.event_id(),
    137             connection_identity: self.connection_identity,
    138             request_identity: self.request_identity,
    139             request_digest: self.request_digest,
    140         }
    141     }
    142 
    143     /// Classifies this candidate against one retained digest-only key.
    144     #[must_use]
    145     pub fn classify_against(&self, retained: MycNip46ReplayKey) -> MycNip46ReplayDisposition {
    146         let same_event = self.event_id() == retained.event_id;
    147         let same_connection = self.connection_identity == retained.connection_identity;
    148         let same_request_identity = self.request_identity == retained.request_identity;
    149         let same_request_bytes = self.request_digest == retained.request_digest;
    150 
    151         if same_event {
    152             if same_connection && same_request_identity && same_request_bytes {
    153                 MycNip46ReplayDisposition::DuplicateEvent
    154             } else {
    155                 MycNip46ReplayDisposition::ConflictingEventReuse
    156             }
    157         } else if same_connection && same_request_identity {
    158             if same_request_bytes {
    159                 MycNip46ReplayDisposition::ExactRequestReplay
    160             } else {
    161                 MycNip46ReplayDisposition::ConflictingRequestReuse
    162             }
    163         } else {
    164             MycNip46ReplayDisposition::DistinctRequest
    165         }
    166     }
    167 }
    168 
    169 impl fmt::Debug for MycReplayBoundNip46Request {
    170     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    171         formatter
    172             .debug_struct("MycReplayBoundNip46Request")
    173             .field("identity", &"[redacted]")
    174             .finish()
    175     }
    176 }
    177 
    178 /// Derives stable replay identities from one verified event/request candidate.
    179 ///
    180 /// The caller must not treat this pure identity projection as proof that the
    181 /// request plaintext came from the encrypted event. Step 145 owns decryption,
    182 /// association, supported-method admission, and conversion into durable work.
    183 pub fn bind_myc_nip46_replay(
    184     event: MycVerifiedNip46Event,
    185     request: MycVerifiedNip46Request,
    186 ) -> Result<MycReplayBoundNip46Request, MycSignerRequestError> {
    187     let connection_identity =
    188         derive_connection_identity(event.receiver_public_key(), event.client_public_key());
    189     let request_identity = MycNip46LogicalRequestIdentity(derive_request_identity(
    190         event.client_public_key(),
    191         request.request_id(),
    192     ));
    193     let request_digest =
    194         MycSignerRequestDigest::for_canonical_request(request.canonical_request())?;
    195 
    196     Ok(MycReplayBoundNip46Request {
    197         event,
    198         request,
    199         connection_identity,
    200         request_identity,
    201         request_digest,
    202     })
    203 }
    204 
    205 fn derive_connection_identity(
    206     receiver_public_key: &MycProviderPublicIdentity,
    207     client_public_key: &MycNip46ClientPublicKey,
    208 ) -> MycNip46ConnectionIdentity {
    209     let mut hasher = Sha256::new();
    210     hasher.update(CONNECTION_IDENTITY_DOMAIN);
    211     update_length_prefixed(&mut hasher, receiver_public_key.as_hex().as_bytes());
    212     update_length_prefixed(&mut hasher, client_public_key.as_hex().as_bytes());
    213     MycNip46ConnectionIdentity(hasher.finalize().into())
    214 }
    215 
    216 fn update_length_prefixed(hasher: &mut Sha256, value: &[u8]) {
    217     hasher.update(
    218         u64::try_from(value.len())
    219             .expect("canonical identity length fits u64")
    220             .to_be_bytes(),
    221     );
    222     hasher.update(value);
    223 }