Skip to main content

xmtp_id/associations/
verified_signature.rs

1#![allow(dead_code)]
2use super::{
3    AccountId, InstallationKeyContext, MemberIdentifier, SignatureError, SignatureKind, ident,
4};
5use crate::scw_verifier::SmartContractSignatureVerifier;
6use alloy::primitives::Signature as EtherSignature;
7use base64::Engine;
8use base64::prelude::BASE64_URL_SAFE_NO_PAD;
9use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier};
10use xmtp_cryptography::CredentialVerify;
11use xmtp_cryptography::hash::sha256_bytes;
12use xmtp_cryptography::signature::h160addr_to_string;
13
14#[derive(Debug, Clone)]
15pub struct VerifiedSignature {
16    pub signer: MemberIdentifier,
17    pub kind: SignatureKind,
18    /// The association-log replay key. Recoverable ECDSA and passkey signatures are canonicalised
19    /// so every accepted encoding yields one key; installation-key and ERC-1271 signatures use the
20    /// submitted bytes.
21    pub replay_key: Vec<u8>,
22    pub chain_id: Option<u64>,
23}
24
25#[derive(serde::Deserialize)]
26struct ClientDataJson {
27    origin: String,
28    challenge: String,
29}
30
31impl VerifiedSignature {
32    pub fn new(
33        signer: MemberIdentifier,
34        kind: SignatureKind,
35        replay_key: Vec<u8>,
36        chain_id: Option<u64>,
37    ) -> Self {
38        Self {
39            signer,
40            kind,
41            replay_key,
42            chain_id,
43        }
44    }
45
46    /// Recovers the signer of a 65-byte `r`, `s`, recovery secp256k1 signature over the
47    /// signature text. The recovery byte must be 0, 1, 27, or 28. The replay key is `r`, `s` in
48    /// the lower half of the curve order, and the recovery parity as 0 or 1.
49    pub fn from_recoverable_ecdsa<Text: AsRef<str>>(
50        signature_text: Text,
51        signature_bytes: &[u8],
52    ) -> Result<Self, SignatureError> {
53        let Ok(bytes @ [.., 0 | 1 | 27 | 28]) = <[u8; 65]>::try_from(signature_bytes) else {
54            return Err(SignatureError::Invalid);
55        };
56        let signature = EtherSignature::from_raw_array(&bytes)?.normalized_s();
57        let address = signature.recover_address_from_msg(signature_text.as_ref())?;
58
59        Ok(Self::new(
60            MemberIdentifier::eth(h160addr_to_string(address))?,
61            SignatureKind::Erc191,
62            signature.as_rsy().to_vec(),
63            None,
64        ))
65    }
66
67    /**
68     * Verifies an ECDSA signature against the provided signature text and ensures that the recovered
69     * address matches the expected address.
70     */
71    pub fn from_recoverable_ecdsa_with_expected_address<Text: AsRef<str>>(
72        signature_text: Text,
73        signature_bytes: &[u8],
74        expected_address: Text,
75    ) -> Result<Self, SignatureError> {
76        let partially_verified = Self::from_recoverable_ecdsa(signature_text, signature_bytes)?;
77        if partially_verified
78            .signer
79            .eth_address()
80            .ok_or(SignatureError::Invalid)?
81            .to_lowercase()
82            != expected_address.as_ref().to_lowercase()
83        {
84            return Err(SignatureError::Invalid);
85        }
86
87        Ok(partially_verified)
88    }
89
90    /**
91     * Verifies an installation key signature against the provided signature text and verifying key bytes.
92     * Returns a VerifiedSignature if the signature is valid, otherwise returns an error.
93     */
94    pub fn from_installation_key<Text: AsRef<str>>(
95        signature_text: Text,
96        signature_bytes: &[u8],
97        verifying_key: ed25519_dalek::VerifyingKey,
98    ) -> Result<Self, SignatureError> {
99        verifying_key.credential_verify::<InstallationKeyContext>(
100            signature_text,
101            signature_bytes.try_into()?,
102        )?;
103        Ok(Self::new(
104            MemberIdentifier::installation(verifying_key.as_bytes().to_vec()),
105            SignatureKind::InstallationKey,
106            signature_bytes.to_vec(),
107            None,
108        ))
109    }
110
111    /// Verifies a WebAuthn P-256 assertion over the signature text. The replay key is the 64-byte
112    /// `r`, `s` with `s` in the lower half of the curve order.
113    pub fn from_passkey<Text: AsRef<str>>(
114        signature_text: Text,
115        public_key: &[u8],
116        signature: &[u8],
117        authenticator_data: &[u8],
118        client_data_json: &[u8],
119    ) -> Result<Self, SignatureError> {
120        let client_data: ClientDataJson = serde_json::from_slice(client_data_json)
121            .map_err(|_| SignatureError::InvalidClientData)?;
122
123        let signature_text = BASE64_URL_SAFE_NO_PAD.encode(signature_text.as_ref());
124        if signature_text != client_data.challenge {
125            // Challenge needs to match signature text
126            return Err(SignatureError::InvalidClientData);
127        }
128
129        // 1. Parse the public key from raw bytes
130        let verifying_key = VerifyingKey::from_sec1_bytes(public_key)
131            .map_err(|_| SignatureError::InvalidPublicKey)?;
132
133        // 2. Parse the signature
134        let signature = Signature::from_der(signature).map_err(|_| SignatureError::Invalid)?;
135
136        // 3. Hash the client data
137        let client_data_hash = sha256_bytes(client_data_json);
138
139        // 4. Construct the verification data (authenticator_data + client_data_hash)
140        let mut verification_data =
141            Vec::with_capacity(authenticator_data.len() + client_data_hash.len());
142        verification_data.extend_from_slice(authenticator_data);
143        verification_data.extend_from_slice(&client_data_hash);
144
145        // 5. Verify the signature
146        verifying_key.verify(&verification_data, &signature)?;
147
148        Ok(Self::new(
149            MemberIdentifier::Passkey(ident::Passkey {
150                key: public_key.to_vec(),
151                relying_party: Some(client_data.origin),
152            }),
153            SignatureKind::P256,
154            signature.normalize_s().unwrap_or(signature).to_vec(),
155            None,
156        ))
157    }
158
159    /// Verifies a smart contract wallet signature using the provided signature verifier.
160    pub async fn from_smart_contract_wallet<Text: AsRef<str>>(
161        signature_text: Text,
162        signature_verifier: impl SmartContractSignatureVerifier,
163        signature_bytes: &[u8],
164        account_id: AccountId,
165        block_number: &mut Option<u64>,
166    ) -> Result<Self, SignatureError> {
167        // implements: IDENT-060
168        let chain_id = account_id.eip155_chain_id()?;
169        let response = signature_verifier
170            .is_valid_signature(
171                account_id.clone(),
172                alloy::primitives::eip191_hash_message(signature_text.as_ref()).into(),
173                signature_bytes.to_vec().into(),
174                *block_number,
175            )
176            .await?;
177
178        if response.is_valid {
179            // set the block the signature was validated on
180            *block_number = response.block_number;
181
182            Ok(Self::new(
183                MemberIdentifier::eth(account_id.get_account_address())?,
184                SignatureKind::Erc1271,
185                signature_bytes.to_vec(),
186                Some(chain_id),
187            ))
188        } else {
189            tracing::error!(
190                "Smart contract wallet signature is invalid {:?}",
191                response.error
192            );
193            Err(SignatureError::Invalid)
194        }
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use crate::{
202        InboxOwner,
203        associations::{
204            InstallationKeyContext, MemberIdentifier, SignatureKind,
205            test_utils::{
206                MockSmartContractSignatureVerifier, WalletTestExt, ecdsa_negated_s_alias,
207                ecdsa_recovery_byte_alias, p256_negated_s_alias,
208            },
209            unverified::UnverifiedSignature,
210            verified_signature::VerifiedSignature,
211        },
212        utils::passkey::PasskeyUser,
213    };
214    use alloy::signers::Signer;
215    use alloy::signers::local::PrivateKeySigner;
216    use xmtp_common::rand_hexstring;
217    use xmtp_cryptography::{CredentialSign, XmtpInstallationCredential};
218
219    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
220    #[cfg_attr(not(target_arch = "wasm32"), tokio::test)]
221    // verifies: IDENT-030
222    async fn test_recoverable_ecdsa() {
223        let wallet = PrivateKeySigner::random();
224        let signature_text = "test signature body";
225
226        let signature = wallet
227            .sign_message(signature_text.as_bytes())
228            .await
229            .unwrap();
230        let verified_sig =
231            VerifiedSignature::from_recoverable_ecdsa(signature_text, &signature.as_bytes())
232                .expect("should succeed");
233
234        assert_eq!(verified_sig.signer, wallet.member_identifier());
235        assert_eq!(verified_sig.kind, SignatureKind::Erc191);
236        assert_eq!(verified_sig.replay_key, signature.as_rsy());
237    }
238
239    /// Every accepted encoding of one wallet signature (recovery byte 0/1 or 27/28, `s` in either
240    /// half) recovers the same signer and yields one replay key: `r`, low `s`, parity as 0 or 1.
241    /// Any other recovery byte or length is rejected, so no further alias exists.
242    #[xmtp_common::test(unwrap_try = true)]
243    // verifies: IDENT-030, IDENT-050
244    async fn identity_replay_aliases_share_a_wallet_key() {
245        let wallet = PrivateKeySigner::random();
246        let text = "replay alias";
247        let signature = wallet.sign_message(text.as_bytes()).await?;
248        let bytes = signature.as_bytes().to_vec();
249        let negated = ecdsa_negated_s_alias(&bytes);
250
251        for form in [
252            ecdsa_recovery_byte_alias(&bytes),
253            ecdsa_recovery_byte_alias(&negated),
254            negated,
255            bytes.clone(),
256        ] {
257            let verified = VerifiedSignature::from_recoverable_ecdsa(text, &form)?;
258            assert_eq!(verified.signer, wallet.member_identifier());
259            assert_eq!(verified.replay_key, signature.as_rsy());
260        }
261
262        for v in [2, 26, 29, 35, 37, 255] {
263            let form = [&bytes[..64], &[v]].concat();
264            assert!(VerifiedSignature::from_recoverable_ecdsa(text, &form).is_err());
265        }
266        assert!(VerifiedSignature::from_recoverable_ecdsa(text, &bytes[..64]).is_err());
267        assert!(
268            VerifiedSignature::from_recoverable_ecdsa(text, &[bytes.as_slice(), &[0]].concat())
269                .is_err()
270        );
271    }
272
273    /// Both `s` forms of one passkey signature verify and yield one 64-byte replay key with `s` in
274    /// the lower half.
275    #[xmtp_common::test(unwrap_try = true)]
276    // verifies: IDENT-030, IDENT-050
277    async fn identity_replay_aliases_share_a_passkey_key() {
278        let passkey = PasskeyUser::new().await;
279        let text = "replay alias";
280        let UnverifiedSignature::Passkey(signature) = passkey.sign(text)? else {
281            unreachable!("a passkey signs with a passkey signature")
282        };
283        let verify = |der: &[u8]| {
284            VerifiedSignature::from_passkey(
285                text,
286                &signature.public_key,
287                der,
288                &signature.authenticator_data,
289                &signature.client_data_json,
290            )
291        };
292
293        let original = verify(&signature.signature)?;
294        let negated = verify(&p256_negated_s_alias(&signature.signature))?;
295        assert_eq!(original.signer, negated.signer);
296        assert_eq!(original.replay_key, negated.replay_key);
297        let low_s = Signature::from_slice(&original.replay_key)?;
298        assert!(low_s.normalize_s().is_none());
299    }
300
301    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
302    #[cfg_attr(not(target_arch = "wasm32"), tokio::test)]
303    // verifies: IDENT-030
304    async fn test_recoverable_ecdsa_incorrect() {
305        let wallet = PrivateKeySigner::random();
306        let signature_text = "test signature body";
307
308        let sig_bytes: Vec<u8> = wallet
309            .sign_message(signature_text.as_bytes())
310            .await
311            .unwrap()
312            .into();
313
314        let verified_sig =
315            VerifiedSignature::from_recoverable_ecdsa("wrong text again", &sig_bytes).unwrap();
316        assert_ne!(verified_sig.signer, wallet.member_identifier());
317    }
318
319    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
320    #[cfg_attr(not(target_arch = "wasm32"), tokio::test)]
321    // verifies: IDENT-030
322    async fn test_installation_key() {
323        let key = XmtpInstallationCredential::new();
324        let verifying_key = key.verifying_key();
325        let signature_text = "test signature text";
326        let sig = key
327            .credential_sign::<InstallationKeyContext>(signature_text)
328            .unwrap();
329
330        let verified_sig =
331            VerifiedSignature::from_installation_key(signature_text, sig.as_slice(), verifying_key)
332                .expect("should succeed");
333        let expected = MemberIdentifier::installation(verifying_key.as_bytes().to_vec());
334        assert_eq!(expected, verified_sig.signer);
335        assert_eq!(SignatureKind::InstallationKey, verified_sig.kind);
336        assert_eq!(verified_sig.replay_key, sig.as_slice());
337
338        // Make sure it fails with the wrong signature text
339        VerifiedSignature::from_installation_key(
340            "wrong signature text",
341            sig.as_slice(),
342            verifying_key,
343        )
344        .expect_err("should fail with incorrect signature text");
345
346        // Make sure it fails with the wrong verifying key
347        VerifiedSignature::from_installation_key(
348            signature_text,
349            sig.as_slice(),
350            XmtpInstallationCredential::new().verifying_key(),
351        )
352        .expect_err("should fail with incorrect verifying key");
353    }
354
355    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
356    #[cfg_attr(not(target_arch = "wasm32"), tokio::test)]
357    async fn test_smart_contract_wallet() {
358        let mock_verifier = MockSmartContractSignatureVerifier::new(true);
359        let chain_id: u64 = 24;
360        let account_address = rand_hexstring();
361        let account_id = AccountId::new(format!("eip155:{chain_id}"), account_address.clone());
362        let signature_text = "test_smart_contract_wallet_signature";
363        let signature_bytes = &[1, 2, 3];
364        let mut block_number = Some(1);
365
366        let verified_sig = VerifiedSignature::from_smart_contract_wallet(
367            signature_text,
368            mock_verifier,
369            signature_bytes,
370            account_id,
371            &mut block_number,
372        )
373        .await
374        .expect("should validate");
375        assert_eq!(
376            verified_sig.signer,
377            MemberIdentifier::eth(account_address).unwrap()
378        );
379        assert_eq!(verified_sig.kind, SignatureKind::Erc1271);
380        assert_eq!(verified_sig.replay_key, signature_bytes);
381        assert_eq!(verified_sig.chain_id, Some(chain_id));
382    }
383}