1use super::handle::{Handle, InvalidHandle, MAX_HANDLE_CLAIMS};
2use super::member::{HasMemberKind, Identifier, Member, MemberIdentifier, MemberKind};
3use super::serialization::DeserializationError;
4use super::signature::{SignatureError, SignatureKind};
5use super::state::AssociationState;
6use super::verified_signature::VerifiedSignature;
7use thiserror::Error;
8use xmtp_common::ErrorCode;
9
10#[derive(Debug, Error, ErrorCode)]
11pub enum AssociationError {
12 #[error("Error creating association {0}")]
16 Generic(String),
17 #[error("Multiple create operations detected")]
21 MultipleCreate,
22 #[error("XID not yet created")]
26 NotCreated,
27 #[error("Signature validation failed {0}")]
28 #[error_code(inherit)]
29 Signature(#[from] SignatureError),
30 #[error("Member of kind {0} not allowed to add {1}")]
34 MemberNotAllowed(MemberKind, MemberKind),
35 #[error("Missing existing member")]
39 MissingExistingMember,
40 #[error("The new member is already a member of the inbox")]
44 AlreadyMember,
45 #[error("Identity update has no actions")]
49 EmptyUpdate,
50 #[error("The new member identifier does not match the signer")]
54 NewMemberIdSignatureMismatch,
55 #[error("Wrong inbox_id specified on association")]
59 WrongInboxId,
60 #[error("Signature not allowed for role {0:?} {1:?}")]
64 SignatureNotAllowed(String, String),
65 #[error("Replay detected")]
69 Replay,
70 #[error("Deserialization error {0}")]
71 #[error_code(inherit)]
72 Deserialization(#[from] DeserializationError),
73 #[error("Missing identity update")]
77 MissingIdentityUpdate,
78 #[error("Wrong chain id. Initially added with {0} but now signing from {1}")]
82 ChainIdMismatch(u64, u64),
83 #[error("Invalid account address: Must be 42 hex characters, starting with '0x'.")]
87 InvalidAccountAddress,
88 #[error("{0} are not a public identifier")]
92 NotIdentifier(String),
93 #[error(transparent)]
94 #[error_code(inherit)]
95 Convert(#[from] xmtp_proto::ConversionError),
96 #[error(transparent)]
97 #[error_code(inherit)]
98 InvalidHandle(#[from] InvalidHandle),
99 #[error("an identity update that claims a handle must carry no other action")]
103 ClaimNotAlone,
104 #[error("the inbox has reached the limit of handle claims")]
108 HandleClaimLimit,
109}
110
111pub trait IdentityAction: Send {
112 fn update_state(
113 &self,
114 existing_state: Option<AssociationState>,
115 client_timestamp_ns: u64,
116 ) -> Result<AssociationState, AssociationError>;
117 fn replay_keys(&self) -> Vec<Vec<u8>>;
119 fn replay_check(&self, state: &AssociationState) -> Result<(), AssociationError> {
121 if self.replay_keys().iter().any(|key| state.has_seen(key)) {
122 return Err(AssociationError::Replay);
123 }
124 Ok(())
125 }
126}
127
128#[derive(Debug, Clone)]
130pub struct CreateInbox {
131 pub nonce: u64,
132 pub account_identifier: Identifier,
133 pub initial_identifier_signature: VerifiedSignature,
134}
135
136impl IdentityAction for CreateInbox {
137 fn update_state(
139 &self,
140 existing_state: Option<AssociationState>,
141 _client_timestamp_ns: u64,
142 ) -> Result<AssociationState, AssociationError> {
143 if existing_state.is_some() {
144 return Err(AssociationError::MultipleCreate);
145 }
146
147 let account_address = self.account_identifier.clone();
148 let recovered_signer = self.initial_identifier_signature.signer.clone();
149 if recovered_signer != account_address {
150 return Err(AssociationError::MissingExistingMember);
151 }
152
153 allowed_signature_for_kind(
154 &self.account_identifier.kind(),
155 &self.initial_identifier_signature.kind,
156 )?;
157
158 AssociationState::new(
159 account_address,
160 self.nonce,
161 self.initial_identifier_signature.chain_id,
162 )
163 }
164
165 fn replay_keys(&self) -> Vec<Vec<u8>> {
166 vec![self.initial_identifier_signature.replay_key.clone()]
167 }
168}
169
170#[derive(Debug, Clone)]
172pub struct AddAssociation {
173 pub new_member_signature: VerifiedSignature,
174 pub new_member_identifier: MemberIdentifier,
175 pub existing_member_signature: VerifiedSignature,
176}
177
178impl IdentityAction for AddAssociation {
179 fn update_state(
181 &self,
182 maybe_existing_state: Option<AssociationState>,
183 client_timestamp_ns: u64,
184 ) -> Result<AssociationState, AssociationError> {
185 let existing_state = maybe_existing_state.ok_or(AssociationError::NotCreated)?;
186 self.replay_check(&existing_state)?;
187
188 let new_member_address = &self.new_member_signature.signer;
190 let existing_member_identifier = &self.existing_member_signature.signer;
192
193 if new_member_address.ne(&self.new_member_identifier) {
194 return Err(AssociationError::NewMemberIdSignatureMismatch);
195 }
196
197 if new_member_address == existing_member_identifier {
199 return Err(AssociationError::Generic("tried to add self".to_string()));
200 }
201
202 if existing_state.get(&self.new_member_identifier).is_some() {
204 return Err(AssociationError::AlreadyMember);
205 }
206
207 allowed_signature_for_kind(
208 &self.new_member_identifier.kind(),
209 &self.new_member_signature.kind,
210 )?;
211
212 let existing_member = existing_state.get(existing_member_identifier);
213
214 if let Some(member) = existing_member {
215 verify_chain_id_matches(member, &self.existing_member_signature)?;
216 }
217
218 let existing_entity_id = match existing_member {
219 Some(member) => member.identifier.clone(),
221 None => {
222 let recovery_identifier = existing_state.recovery_identifier().clone().into();
224
225 if *existing_member_identifier != recovery_identifier {
227 return Err(AssociationError::MissingExistingMember);
228 }
229 recovery_identifier
230 }
231 };
232
233 allowed_signature_for_kind(
235 &existing_entity_id.kind(),
236 &self.existing_member_signature.kind,
237 )?;
238
239 allowed_association(
241 existing_member_identifier.kind(),
242 self.new_member_identifier.kind(),
243 )?;
244
245 let new_member = Member::new(
246 new_member_address.clone(),
247 Some(existing_entity_id),
248 Some(client_timestamp_ns),
249 self.new_member_signature.chain_id,
250 );
251
252 Ok(existing_state.add(new_member))
253 }
254
255 fn replay_keys(&self) -> Vec<Vec<u8>> {
256 vec![
257 self.existing_member_signature.replay_key.clone(),
258 self.new_member_signature.replay_key.clone(),
259 ]
260 }
261}
262
263#[derive(Debug, Clone)]
265pub struct RevokeAssociation {
266 pub recovery_identifier_signature: VerifiedSignature,
267 pub revoked_member: MemberIdentifier,
268}
269
270impl IdentityAction for RevokeAssociation {
271 fn update_state(
273 &self,
274 maybe_existing_state: Option<AssociationState>,
275 _client_timestamp_ns: u64,
276 ) -> Result<AssociationState, AssociationError> {
277 let existing_state = maybe_existing_state.ok_or(AssociationError::NotCreated)?;
278 self.replay_check(&existing_state)?;
279
280 let existing_member = existing_state.get(&self.recovery_identifier_signature.signer);
282 if let Some(member) = existing_member {
283 verify_chain_id_matches(member, &self.recovery_identifier_signature)?;
284 }
285
286 let recovery_signer = &self.recovery_identifier_signature.signer;
288 let state_recovery_identifier: MemberIdentifier =
290 existing_state.recovery_identifier.clone().into();
291
292 if *recovery_signer != state_recovery_identifier {
293 return Err(AssociationError::MissingExistingMember);
294 }
295
296 let installations_to_remove: Vec<Member> = existing_state
297 .members_by_parent(&self.revoked_member)
298 .into_iter()
299 .filter(|child| child.kind() == MemberKind::Installation)
301 .collect();
302
303 let new_state = existing_state.remove(&self.revoked_member);
305
306 Ok(installations_to_remove
307 .iter()
308 .fold(new_state, |state, installation| {
309 state.remove(&installation.identifier)
310 }))
311 }
312
313 fn replay_keys(&self) -> Vec<Vec<u8>> {
314 vec![self.recovery_identifier_signature.replay_key.clone()]
315 }
316}
317
318#[derive(Debug, Clone)]
320pub struct ChangeRecoveryIdentity {
321 pub recovery_identifier_signature: VerifiedSignature,
322 pub new_recovery_identifier: Identifier,
323}
324
325impl IdentityAction for ChangeRecoveryIdentity {
326 fn update_state(
327 &self,
328 existing_state: Option<AssociationState>,
329 _client_timestamp_ns: u64,
330 ) -> Result<AssociationState, AssociationError> {
331 let existing_state = existing_state.ok_or(AssociationError::NotCreated)?;
332 self.replay_check(&existing_state)?;
333
334 let existing_member = existing_state.get(&self.recovery_identifier_signature.signer);
335 if let Some(member) = existing_member {
336 verify_chain_id_matches(member, &self.recovery_identifier_signature)?;
337 }
338
339 let recovery_signer = &self.recovery_identifier_signature.signer;
340 if existing_state.recovery_identifier() != recovery_signer {
341 return Err(AssociationError::MissingExistingMember);
342 }
343
344 Ok(existing_state.set_recovery_identifier(self.new_recovery_identifier.clone()))
345 }
346
347 fn replay_keys(&self) -> Vec<Vec<u8>> {
348 vec![self.recovery_identifier_signature.replay_key.clone()]
349 }
350}
351
352#[derive(Debug, Clone)]
357pub struct ClaimHandle {
358 pub handle: String,
359 pub signature: VerifiedSignature,
360}
361
362impl IdentityAction for ClaimHandle {
363 fn update_state(
369 &self,
370 existing_state: Option<AssociationState>,
371 _client_timestamp_ns: u64,
372 ) -> Result<AssociationState, AssociationError> {
373 let existing_state = existing_state.ok_or(AssociationError::NotCreated)?;
374 self.replay_check(&existing_state)?;
375 let handle = Handle::parse(&self.handle)?;
376
377 let signer = existing_state
379 .get(&self.signature.signer)
380 .ok_or(AssociationError::MissingExistingMember)?;
381 verify_chain_id_matches(signer, &self.signature)?;
382 allowed_signature_for_kind(&signer.kind(), &self.signature.kind)?;
383
384 if existing_state.handle_claims() >= MAX_HANDLE_CLAIMS {
385 return Err(AssociationError::HandleClaimLimit);
386 }
387
388 Ok(existing_state.claim_handle(handle))
389 }
390
391 fn replay_keys(&self) -> Vec<Vec<u8>> {
392 vec![self.signature.replay_key.clone()]
393 }
394}
395
396#[derive(Debug, Clone)]
398pub enum Action {
399 CreateInbox(CreateInbox),
400 AddAssociation(AddAssociation),
401 RevokeAssociation(RevokeAssociation),
402 ChangeRecoveryIdentity(ChangeRecoveryIdentity),
403 ClaimHandle(ClaimHandle),
404}
405
406impl IdentityAction for Action {
407 fn update_state(
408 &self,
409 existing_state: Option<AssociationState>,
410 client_timestamp_ns: u64,
411 ) -> Result<AssociationState, AssociationError> {
412 match self {
413 Action::CreateInbox(event) => event.update_state(existing_state, client_timestamp_ns),
414 Action::AddAssociation(event) => {
415 event.update_state(existing_state, client_timestamp_ns)
416 }
417 Action::RevokeAssociation(event) => {
418 event.update_state(existing_state, client_timestamp_ns)
419 }
420 Action::ChangeRecoveryIdentity(event) => {
421 event.update_state(existing_state, client_timestamp_ns)
422 }
423 Action::ClaimHandle(event) => event.update_state(existing_state, client_timestamp_ns),
424 }
425 }
426
427 fn replay_keys(&self) -> Vec<Vec<u8>> {
428 match self {
429 Action::CreateInbox(event) => event.replay_keys(),
430 Action::AddAssociation(event) => event.replay_keys(),
431 Action::RevokeAssociation(event) => event.replay_keys(),
432 Action::ChangeRecoveryIdentity(event) => event.replay_keys(),
433 Action::ClaimHandle(event) => event.replay_keys(),
434 }
435 }
436}
437
438#[derive(Debug, Clone)]
440pub struct IdentityUpdate {
441 pub inbox_id: String,
442 pub client_timestamp_ns: u64,
443 pub actions: Vec<Action>,
444}
445
446impl IdentityUpdate {
447 pub fn new(actions: Vec<Action>, inbox_id: String, client_timestamp_ns: u64) -> Self {
448 Self {
449 inbox_id,
450 actions,
451 client_timestamp_ns,
452 }
453 }
454
455 pub fn creation_signature_kind(&self) -> Option<SignatureKind> {
461 self.actions.iter().find_map(|action| match action {
462 Action::CreateInbox(create_inbox) => {
463 Some(create_inbox.initial_identifier_signature.kind.clone())
464 }
465 _ => None,
466 })
467 }
468
469 pub fn claims_handle(&self) -> bool {
472 self.actions
473 .iter()
474 .any(|action| matches!(action, Action::ClaimHandle(_)))
475 }
476}
477
478impl IdentityAction for IdentityUpdate {
479 fn update_state(
481 &self,
482 existing_state: Option<AssociationState>,
483 _client_timestamp_ns: u64,
484 ) -> Result<AssociationState, AssociationError> {
485 if self.actions.is_empty() {
486 return Err(AssociationError::EmptyUpdate);
487 }
488 if self.actions.len() > 1 && self.claims_handle() {
491 return Err(AssociationError::ClaimNotAlone);
492 }
493 let mut state = existing_state;
494 for action in &self.actions {
495 state = Some(action.update_state(state, self.client_timestamp_ns)?);
496 }
497
498 let new_state = state.ok_or(AssociationError::NotCreated)?;
499 if new_state.inbox_id().ne(&self.inbox_id) {
500 tracing::error!(
501 "state inbox id mismatch, old: {}, new: {}",
502 self.inbox_id,
503 new_state.inbox_id()
504 );
505 return Err(AssociationError::WrongInboxId);
506 }
507
508 Ok(new_state.add_seen_signatures(self.replay_keys()))
511 }
512
513 fn replay_keys(&self) -> Vec<Vec<u8>> {
514 self.actions
515 .iter()
516 .flat_map(|action| action.replay_keys())
517 .collect()
518 }
519}
520
521fn allowed_association(
523 existing_member_kind: MemberKind,
524 new_member_kind: MemberKind,
525) -> Result<(), AssociationError> {
526 if existing_member_kind == MemberKind::Installation
528 && new_member_kind == MemberKind::Installation
529 {
530 return Err(AssociationError::MemberNotAllowed(
531 existing_member_kind,
532 new_member_kind,
533 ));
534 }
535
536 Ok(())
537}
538
539fn allowed_signature_for_kind(
542 role: &MemberKind,
543 signature_kind: &SignatureKind,
544) -> Result<(), AssociationError> {
545 let is_ok = match role {
546 MemberKind::Ethereum => matches!(
547 signature_kind,
548 SignatureKind::Erc191 | SignatureKind::Erc1271
549 ),
550 MemberKind::Installation => matches!(signature_kind, SignatureKind::InstallationKey),
551 MemberKind::Passkey => matches!(signature_kind, SignatureKind::P256),
552 };
553
554 if !is_ok {
555 return Err(AssociationError::SignatureNotAllowed(
556 role.to_string(),
557 signature_kind.to_string(),
558 ));
559 }
560
561 Ok(())
562}
563
564fn verify_chain_id_matches(
565 member: &Member,
566 signature: &VerifiedSignature,
567) -> Result<(), AssociationError> {
568 if member.added_on_chain_id != signature.chain_id {
569 return Err(AssociationError::ChainIdMismatch(
570 member.added_on_chain_id.unwrap_or(0),
571 signature.chain_id.unwrap_or(0),
572 ));
573 }
574
575 Ok(())
576}
577
578#[cfg(test)]
579mod tests {
580 use super::*;
581 use crate::{
582 InboxOwner,
583 associations::{
584 apply_update,
585 builder::SignatureRequestBuilder,
586 get_state,
587 test_utils::{
588 MockSmartContractSignatureVerifier, WalletTestExt, ecdsa_negated_s_alias,
589 ecdsa_recovery_byte_alias, p256_negated_s_alias,
590 },
591 unverified::{UnverifiedAction, UnverifiedIdentityUpdate, UnverifiedSignature},
592 },
593 utils::passkey::PasskeyUser,
594 };
595 use alloy::signers::local::PrivateKeySigner;
596
597 async fn sign(
598 builder: SignatureRequestBuilder,
599 owners: &[&dyn InboxOwner],
600 ) -> UnverifiedIdentityUpdate {
601 let mut request = builder.build();
602 for owner in owners {
603 let signature = owner.sign(&request.signature_text()).unwrap();
604 request
605 .add_signature(signature, MockSmartContractSignatureVerifier::new(false))
606 .await
607 .unwrap();
608 }
609 request.build_identity_update().unwrap()
610 }
611
612 async fn verify(update: &UnverifiedIdentityUpdate) -> IdentityUpdate {
613 update
614 .to_verified(MockSmartContractSignatureVerifier::new(false))
615 .await
616 .unwrap()
617 }
618
619 fn reencode(
621 update: &UnverifiedIdentityUpdate,
622 alias: impl Fn(&mut UnverifiedSignature),
623 ) -> UnverifiedIdentityUpdate {
624 let mut update = update.clone();
625 for action in &mut update.actions {
626 match action {
627 UnverifiedAction::CreateInbox(a) => alias(&mut a.initial_identifier_signature),
628 UnverifiedAction::AddAssociation(a) => {
629 alias(&mut a.existing_member_signature);
630 alias(&mut a.new_member_signature);
631 }
632 UnverifiedAction::RevokeAssociation(a) => {
633 alias(&mut a.recovery_identifier_signature)
634 }
635 UnverifiedAction::ChangeRecoveryAddress(a) => {
636 alias(&mut a.recovery_identifier_signature)
637 }
638 UnverifiedAction::ClaimHandle(a) => alias(&mut a.signature),
639 }
640 }
641 update
642 }
643
644 fn recovery_byte_form(signature: &mut UnverifiedSignature) {
645 if let UnverifiedSignature::RecoverableEcdsa(s) = signature {
646 s.signature_bytes = ecdsa_recovery_byte_alias(&s.signature_bytes);
647 }
648 }
649
650 fn negated_s_form(signature: &mut UnverifiedSignature) {
651 match signature {
652 UnverifiedSignature::RecoverableEcdsa(s) => {
653 s.signature_bytes = ecdsa_negated_s_alias(&s.signature_bytes)
654 }
655 UnverifiedSignature::Passkey(s) => s.signature = p256_negated_s_alias(&s.signature),
656 _ => {}
657 }
658 }
659
660 async fn add_then_revoke(
663 recovery: &PrivateKeySigner,
664 member: &dyn InboxOwner,
665 ) -> (AssociationState, AssociationState, UnverifiedIdentityUpdate) {
666 let inbox_id = recovery.get_inbox_id(0);
667 let recovery_id = recovery.identifier();
668 let member_id: MemberIdentifier = member.get_identifier().unwrap().into();
669 let create = sign(
670 SignatureRequestBuilder::new(&inbox_id).create_inbox(recovery_id.clone(), 0),
671 &[recovery],
672 )
673 .await;
674 let add = sign(
675 SignatureRequestBuilder::new(&inbox_id)
676 .add_association(member_id.clone(), recovery_id.clone().into()),
677 &[recovery, member],
678 )
679 .await;
680 let revoke = sign(
681 SignatureRequestBuilder::new(&inbox_id)
682 .revoke_association(recovery_id.into(), member_id.clone()),
683 &[recovery],
684 )
685 .await;
686
687 let created = get_state([verify(&create).await]).unwrap();
688 let added = apply_update(created.clone(), verify(&add).await).unwrap();
689 assert!(added.get(&member_id).is_some());
690 let revoked = apply_update(added, verify(&revoke).await).unwrap();
691 assert!(revoked.get(&member_id).is_none());
692 (created, revoked, add)
693 }
694
695 async fn assert_alias_is_replay(
699 created: &AssociationState,
700 revoked: &AssociationState,
701 add: &UnverifiedIdentityUpdate,
702 member: &MemberIdentifier,
703 alias: impl Fn(&mut UnverifiedSignature),
704 ) {
705 let replay = reencode(add, alias);
706 assert_ne!(&replay, add, "the alias must change the signature bytes");
707 let replay = verify(&replay).await;
708 let fresh = apply_update(created.clone(), replay.clone()).unwrap();
709 assert!(fresh.get(member).is_some(), "the alias is a valid add");
710
711 let result = apply_update(revoked.clone(), replay);
712 assert!(
713 matches!(result, Err(AssociationError::Replay)),
714 "{result:?}"
715 );
716 assert!(revoked.get(member).is_none());
717 }
718
719 #[xmtp_common::test]
723 async fn identity_replay_aliases_wallet_add_after_revoke() {
725 let recovery = PrivateKeySigner::random();
726 let member = PrivateKeySigner::random();
727 let member_id = member.member_identifier();
728 let (created, revoked, add) = add_then_revoke(&recovery, &member).await;
729
730 assert_alias_is_replay(&created, &revoked, &add, &member_id, recovery_byte_form).await;
731 assert_alias_is_replay(&created, &revoked, &add, &member_id, negated_s_form).await;
732 assert_alias_is_replay(&created, &revoked, &add, &member_id, |s| {
733 negated_s_form(s);
734 recovery_byte_form(s);
735 })
736 .await;
737 }
738
739 #[xmtp_common::test]
742 async fn identity_replay_aliases_passkey_add_after_revoke() {
744 let recovery = PrivateKeySigner::random();
745 let passkey = PasskeyUser::new().await;
746 let member_id: MemberIdentifier = passkey.identifier().into();
747 let (created, revoked, add) = add_then_revoke(&recovery, &passkey).await;
748
749 assert_alias_is_replay(&created, &revoked, &add, &member_id, |s| match s {
750 UnverifiedSignature::Passkey(_) => negated_s_form(s),
751 _ => recovery_byte_form(s),
752 })
753 .await;
754 }
755}