Skip to main content

xmtp_id/associations/
member.rs

1use super::{AssociationError, DeserializationError, ident};
2use ed25519_dalek::VerifyingKey;
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::{
6    fmt::{Debug, Display},
7    hash::Hash,
8};
9use xmtp_cryptography::{XmtpInstallationCredential, signature::IdentifierValidationError};
10use xmtp_proto::types::ApiIdentifier;
11use xmtp_proto::{
12    ConversionError,
13    xmtp::identity::{
14        api::v1::get_inbox_ids_request::Request as GetInboxIdsRequestProto,
15        associations::IdentifierKind,
16    },
17};
18
19#[derive(Clone, Eq, PartialEq, Hash)]
20/// All identity logic happens here
21pub enum MemberIdentifier {
22    Installation(ident::Installation),
23    Ethereum(ident::Ethereum),
24    Passkey(ident::Passkey),
25}
26
27#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
28/// MemberIdentifier without the installation variant
29/// is used to enforce parameters.
30/// Not everything in this enum will be able to sign,
31/// which will be enforced on the unverified signature counterparts.
32pub enum Identifier {
33    Ethereum(ident::Ethereum),
34    Passkey(ident::Passkey),
35}
36
37impl From<Identifier> for i32 {
38    fn from(value: Identifier) -> Self {
39        IdentifierKind::from(value).into()
40    }
41}
42
43impl From<&Identifier> for i32 {
44    fn from(value: &Identifier) -> Self {
45        IdentifierKind::from(value).into()
46    }
47}
48impl MemberIdentifier {
49    pub fn sanitize(self) -> Result<Self, IdentifierValidationError> {
50        let ident = match self {
51            Self::Ethereum(addr) => Self::Ethereum(addr.sanitize()?),
52            ident => ident,
53        };
54        Ok(ident)
55    }
56
57    #[cfg(any(test, feature = "test-utils"))]
58    pub fn rand_ethereum() -> Self {
59        Self::Ethereum(ident::Ethereum::rand())
60    }
61
62    #[cfg(any(test, feature = "test-utils"))]
63    pub fn rand_installation() -> Self {
64        Self::Installation(ident::Installation::rand())
65    }
66
67    pub fn eth(addr: impl ToString) -> Result<Self, IdentifierValidationError> {
68        Ok(Identifier::eth(addr)?.into())
69    }
70
71    pub fn installation(key: Vec<u8>) -> Self {
72        Self::Installation(ident::Installation(key))
73    }
74
75    /// Get the value for [`MemberIdentifier::Installation`] variant.
76    /// Returns `None` if the type is not the correct variant.
77    pub fn installation_key(&self) -> Option<&[u8]> {
78        if let Self::Installation(installation) = self {
79            Some(&installation.0)
80        } else {
81            None
82        }
83    }
84
85    /// Get the value for [`MemberIdentifier::Ethereum`] variant.
86    /// Returns `None` if the type is not the correct variant.
87    pub fn eth_address(&self) -> Option<&str> {
88        if let Self::Ethereum(address) = self {
89            Some(&address.0)
90        } else {
91            None
92        }
93    }
94
95    /// Get the value for [`MemberIdentifier::Ethereum`], consuming the [`MemberIdentifier`]
96    /// in the process
97    pub fn to_eth_address(self) -> Option<String> {
98        if let Self::Ethereum(address) = self {
99            Some(address.0)
100        } else {
101            None
102        }
103    }
104
105    /// Get the value for [`MemberIdentifier::Installation`] variant.
106    /// Returns `None` if the type is not the correct variant.
107    pub fn to_installation(&self) -> Option<&[u8]> {
108        if let Self::Installation(installation) = self {
109            Some(&installation.0)
110        } else {
111            None
112        }
113    }
114}
115
116impl Identifier {
117    #[cfg(any(test, feature = "test-utils"))]
118    pub fn rand_ethereum() -> Self {
119        Self::Ethereum(ident::Ethereum::rand())
120    }
121
122    pub fn sanitize(self) -> Result<Self, IdentifierValidationError> {
123        let ident = match self {
124            Self::Ethereum(addr) => Self::Ethereum(addr.sanitize()?),
125            ident => ident,
126        };
127        Ok(ident)
128    }
129
130    pub fn eth(addr: impl ToString) -> Result<Self, IdentifierValidationError> {
131        Self::Ethereum(ident::Ethereum(addr.to_string())).sanitize()
132    }
133
134    pub fn passkey(key: Vec<u8>, relying_party: Option<String>) -> Self {
135        Self::Passkey(ident::Passkey { key, relying_party })
136    }
137
138    pub fn passkey_str(
139        key: &str,
140        relying_party: Option<String>,
141    ) -> Result<Self, IdentifierValidationError> {
142        Ok(Self::Passkey(ident::Passkey {
143            key: hex::decode(key)?,
144            relying_party,
145        }))
146    }
147
148    pub fn from_proto(
149        ident: impl AsRef<str>,
150        kind: IdentifierKind,
151        relying_party: Option<String>,
152    ) -> Result<Self, ConversionError> {
153        let ident = ident.as_ref();
154        let ident = match kind {
155            IdentifierKind::Unspecified | IdentifierKind::Ethereum => {
156                Self::Ethereum(ident::Ethereum(ident.to_string()))
157            }
158            IdentifierKind::Passkey => Self::Passkey(ident::Passkey {
159                key: hex::decode(ident).map_err(|_| ConversionError::InvalidPublicKey {
160                    description: "passkey",
161                    value: None,
162                })?,
163                relying_party,
164            }),
165            IdentifierKind::Handle => {
166                return Err(ConversionError::NonSigningIdentifierKind(
167                    IdentifierKind::Handle as i32,
168                ));
169            }
170        };
171        Ok(ident)
172    }
173
174    /// Get the generated inbox_id for this public identifier.
175    /// The same public identifier will always give the same inbox_id.
176    // implements: IDENT-010
177    pub fn inbox_id(&self, nonce: u64) -> Result<String, AssociationError> {
178        if !self.is_valid_address() {
179            return Err(AssociationError::InvalidAccountAddress);
180        }
181        let ident: MemberIdentifier = self.clone().into();
182        Ok(sha256_string(format!("{ident}{nonce}")))
183    }
184
185    /// Validates that the account address is exactly 42 characters, starts with "0x",
186    /// and contains only valid hex digits.
187    fn is_valid_address(&self) -> bool {
188        match self {
189            Self::Ethereum(ident::Ethereum(addr)) => {
190                addr.len() == 42
191                    && addr.starts_with("0x")
192                    && addr[2..].chars().all(|c| c.is_ascii_hexdigit())
193            }
194            _ => true,
195        }
196    }
197}
198
199#[derive(Clone, Debug, PartialEq)]
200pub enum MemberKind {
201    Installation,
202    Ethereum,
203    Passkey,
204}
205
206impl Display for MemberKind {
207    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
208        match self {
209            MemberKind::Installation => write!(f, "installation"),
210            MemberKind::Ethereum => write!(f, "ethereum"),
211            MemberKind::Passkey => write!(f, "passkey"),
212        }
213    }
214}
215
216pub trait HasMemberKind {
217    fn kind(&self) -> MemberKind;
218}
219
220impl HasMemberKind for MemberIdentifier {
221    fn kind(&self) -> MemberKind {
222        match self {
223            Self::Installation(_) => MemberKind::Installation,
224            Self::Ethereum(_) => MemberKind::Ethereum,
225            Self::Passkey(_) => MemberKind::Passkey,
226        }
227    }
228}
229impl HasMemberKind for Identifier {
230    fn kind(&self) -> MemberKind {
231        match self {
232            Self::Ethereum(_) => MemberKind::Ethereum,
233            Self::Passkey(_) => MemberKind::Passkey,
234        }
235    }
236}
237
238impl Display for MemberIdentifier {
239    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
240        match self {
241            Self::Ethereum(eth) => write!(f, "{eth}"),
242            Self::Installation(ident) => write!(f, "{ident}"),
243            Self::Passkey(passkey) => write!(f, "{passkey}"),
244        }
245    }
246}
247
248impl Debug for MemberIdentifier {
249    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250        match self {
251            Self::Installation(ident::Installation(key)) => f
252                .debug_tuple("Installation")
253                .field(&hex::encode(key))
254                .finish(),
255            Self::Ethereum(ident::Ethereum(addr)) => f.debug_tuple("Address").field(addr).finish(),
256            Self::Passkey(ident::Passkey { key, .. }) => {
257                f.debug_tuple("Passkey").field(&hex::encode(key)).finish()
258            }
259        }
260    }
261}
262
263impl Display for Identifier {
264    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265        match self {
266            Self::Ethereum(eth) => write!(f, "{eth}"),
267            Self::Passkey(passkey) => write!(f, "{passkey}"),
268        }
269    }
270}
271
272impl From<VerifyingKey> for MemberIdentifier {
273    fn from(installation: VerifyingKey) -> Self {
274        Self::Installation(ident::Installation(installation.as_bytes().to_vec()))
275    }
276}
277
278impl<'a> From<&'a XmtpInstallationCredential> for MemberIdentifier {
279    fn from(cred: &'a XmtpInstallationCredential) -> MemberIdentifier {
280        MemberIdentifier::Installation(ident::Installation(cred.public_slice().to_vec()))
281    }
282}
283
284impl From<XmtpInstallationCredential> for MemberIdentifier {
285    fn from(cred: XmtpInstallationCredential) -> MemberIdentifier {
286        MemberIdentifier::Installation(ident::Installation(cred.public_slice().to_vec()))
287    }
288}
289
290impl From<Identifier> for MemberIdentifier {
291    fn from(ident: Identifier) -> Self {
292        match ident {
293            Identifier::Ethereum(addr) => Self::Ethereum(addr),
294            Identifier::Passkey(passkey) => Self::Passkey(passkey),
295        }
296    }
297}
298impl From<MemberIdentifier> for Option<Identifier> {
299    fn from(ident: MemberIdentifier) -> Self {
300        let ident = match ident {
301            MemberIdentifier::Passkey(passkey) => Identifier::Passkey(passkey),
302            MemberIdentifier::Ethereum(eth) => Identifier::Ethereum(eth),
303            _ => {
304                return None;
305            }
306        };
307        Some(ident)
308    }
309}
310impl From<&Identifier> for GetInboxIdsRequestProto {
311    fn from(ident: &Identifier) -> Self {
312        Self {
313            identifier: format!("{ident}"),
314            identifier_kind: {
315                let kind: IdentifierKind = ident.into();
316                kind as i32
317            },
318        }
319    }
320}
321
322impl From<&Identifier> for ApiIdentifier {
323    fn from(ident: &Identifier) -> Self {
324        Self {
325            identifier: format!("{ident}"),
326            identifier_kind: ident.into(),
327        }
328    }
329}
330impl From<Identifier> for ApiIdentifier {
331    fn from(ident: Identifier) -> Self {
332        (&ident).into()
333    }
334}
335impl TryFrom<ApiIdentifier> for Identifier {
336    type Error = DeserializationError;
337    fn try_from(ident: ApiIdentifier) -> Result<Self, Self::Error> {
338        let ident = match ident.identifier_kind {
339            IdentifierKind::Unspecified | IdentifierKind::Ethereum => {
340                Identifier::eth(ident.identifier)?
341            }
342            IdentifierKind::Passkey => Identifier::Passkey(ident::Passkey {
343                key: hex::decode(ident.identifier)
344                    .map_err(|_| DeserializationError::InvalidPasskey)?,
345                relying_party: None,
346            }),
347            IdentifierKind::Handle => return Err(DeserializationError::HandleNotIdentifier),
348        };
349        Ok(ident)
350    }
351}
352
353/// A Member of Inbox
354#[derive(Clone, PartialEq)]
355pub struct Member {
356    pub identifier: MemberIdentifier,
357    pub added_by_entity: Option<MemberIdentifier>,
358    pub client_timestamp_ns: Option<u64>,
359    pub added_on_chain_id: Option<u64>,
360}
361
362impl std::fmt::Debug for Member {
363    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
364        write!(
365            f,
366            "Member {{ identifier: {:?}, added_by {:?}, client_timestamp: {:?}, added_on_chain: {:?} }}",
367            self.identifier, self.added_by_entity, self.client_timestamp_ns, self.added_on_chain_id
368        )
369    }
370}
371
372impl Member {
373    pub fn new(
374        identifier: MemberIdentifier,
375        added_by_entity: Option<MemberIdentifier>,
376        client_timestamp_ns: Option<u64>,
377        added_on_chain_id: Option<u64>,
378    ) -> Self {
379        Self {
380            identifier,
381            added_by_entity,
382            client_timestamp_ns,
383            added_on_chain_id,
384        }
385    }
386
387    pub fn kind(&self) -> MemberKind {
388        self.identifier.kind()
389    }
390}
391
392impl PartialEq<MemberIdentifier> for Member {
393    fn eq(&self, other: &MemberIdentifier) -> bool {
394        self.identifier.eq(other)
395    }
396}
397impl PartialEq<MemberIdentifier> for Identifier {
398    fn eq(&self, other: &MemberIdentifier) -> bool {
399        match (self, other) {
400            (Self::Ethereum(ident), MemberIdentifier::Ethereum(other_ident)) => {
401                ident == other_ident
402            }
403            (Self::Passkey(ident), MemberIdentifier::Passkey(other_ident)) => ident == other_ident,
404            _ => false,
405        }
406    }
407}
408impl PartialEq<Identifier> for MemberIdentifier {
409    fn eq(&self, other: &Identifier) -> bool {
410        other == self
411    }
412}
413
414/// Helper function to generate a SHA256 hash as a hex string.
415fn sha256_string(input: String) -> String {
416    let mut hasher = Sha256::new();
417    hasher.update(input.as_bytes());
418    format!("{:x}", hasher.finalize())
419}
420
421#[cfg(test)]
422pub(crate) mod tests {
423    use super::*;
424
425    #[allow(clippy::derivable_impls)]
426    impl Default for Member {
427        fn default() -> Self {
428            Self {
429                identifier: MemberIdentifier::rand_ethereum(),
430                added_by_entity: None,
431                client_timestamp_ns: None,
432                added_on_chain_id: None,
433            }
434        }
435    }
436
437    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
438    #[cfg_attr(not(target_arch = "wasm32"), test)]
439    fn test_identifier_comparisons() {
440        let address_1 = MemberIdentifier::rand_ethereum();
441        let address_2 = MemberIdentifier::rand_ethereum();
442        let address_1_copy = address_1.clone();
443
444        assert!(address_1 != address_2);
445        assert!(address_1.ne(&address_2));
446        assert!(address_1 == address_1_copy);
447
448        let installation_1 = MemberIdentifier::installation([1, 2, 3].to_vec());
449        let installation_2 = MemberIdentifier::installation([4, 5, 6].to_vec());
450        let installation_1_copy = MemberIdentifier::installation([1, 2, 3].to_vec());
451
452        assert!(installation_1 != installation_2);
453        assert!(installation_1.ne(&installation_2));
454        assert!(installation_1 == installation_1_copy);
455    }
456
457    /// A handle signs nothing, so no conversion reads a handle kind as an
458    /// account identifier: not a wire kind, and not the recovery identifier of
459    /// a stored association state. The error names the kind, never Ethereum.
460    #[xmtp_common::test(unwrap_try = true)]
461    fn handle_kind_does_not_convert_to_an_identifier() {
462        assert!(matches!(
463            Identifier::from_proto("nick", IdentifierKind::Handle, None),
464            Err(ConversionError::NonSigningIdentifierKind(3))
465        ));
466        let stored = xmtp_proto::xmtp::identity::associations::AssociationState {
467            inbox_id: Identifier::rand_ethereum().inbox_id(0)?,
468            recovery_identifier: "nick".into(),
469            recovery_identifier_kind: IdentifierKind::Handle.into(),
470            ..Default::default()
471        };
472        assert!(matches!(
473            super::super::AssociationState::try_from(stored),
474            Err(ConversionError::NonSigningIdentifierKind(3))
475        ));
476    }
477
478    /// A lookup identifier of the handle kind names an inbox, not an account,
479    /// so it does not convert to an account identifier.
480    #[xmtp_common::test(unwrap_try = true)]
481    fn handle_lookup_is_not_an_account_identifier() {
482        let lookup = ApiIdentifier {
483            identifier: "nick".into(),
484            identifier_kind: IdentifierKind::Handle,
485        };
486        assert!(matches!(
487            Identifier::try_from(lookup),
488            Err(DeserializationError::HandleNotIdentifier)
489        ));
490    }
491}