Skip to main content

xmtp_id/associations/
signature.rs

1use alloy::signers::{SignerSync, local::LocalSigner};
2use ed25519_dalek::{DigestSigner, Signature, VerifyingKey};
3use prost::Message;
4use sha2::{Digest as _, Sha512};
5use std::array::TryFromSliceError;
6use thiserror::Error;
7use xmtp_common::{ErrorCode, RetryableError};
8use xmtp_cryptography::{
9    CredentialSign, CredentialVerify, SignerError, SigningContextProvider,
10    XmtpInstallationCredential,
11};
12use xmtp_proto::xmtp::message_contents::{
13    SignedPrivateKey as LegacySignedPrivateKeyProto, signed_private_key,
14};
15
16use super::{
17    unverified::{UnverifiedLegacyDelegatedSignature, UnverifiedRecoverableEcdsaSignature},
18    verified_signature::VerifiedSignature,
19};
20
21use alloy::signers::k256::ecdsa::Signature as K256Signature;
22
23#[derive(Debug, Error, ErrorCode)]
24pub enum SignatureError {
25    /// Malformed legacy key.
26    ///
27    /// Legacy key format is invalid. Not retryable.
28    #[error("Malformed legacy key: {0}")]
29    MalformedLegacyKey(String),
30    #[error(transparent)]
31    #[error_code(inherit)]
32    CryptoSignatureError(#[from] xmtp_cryptography::signature::SignatureError),
33    #[error(transparent)]
34    #[error_code(inherit)]
35    VerifierError(#[from] crate::scw_verifier::VerifierError),
36    /// Ed25519 signature failed.
37    ///
38    /// Ed25519 signature verification failed. Not retryable.
39    #[error("ed25519 Signature failed {0}")]
40    Ed25519Error(#[from] ed25519_dalek::SignatureError),
41    /// Slice conversion error.
42    ///
43    /// Byte slice conversion failed. Not retryable.
44    #[error(transparent)]
45    TryFromSliceError(#[from] TryFromSliceError),
46    /// Signature validation failed.
47    ///
48    /// Signature did not verify. Not retryable.
49    #[error("Signature validation failed")]
50    Invalid,
51    #[error(transparent)]
52    #[error_code(inherit)]
53    AddressValidationError(#[from] xmtp_cryptography::signature::IdentifierValidationError),
54    /// URL parse error.
55    ///
56    /// URL parsing failed. Not retryable.
57    #[error(transparent)]
58    UrlParseError(#[from] url::ParseError),
59    /// Decode error.
60    ///
61    /// Protobuf decoding failed. Not retryable.
62    #[error(transparent)]
63    DecodeError(#[from] prost::DecodeError),
64    #[error(transparent)]
65    #[error_code(inherit)]
66    AccountIdError(#[from] AccountIdError),
67    /// Signer error.
68    ///
69    /// Cryptographic signer operation failed. Not retryable.
70    #[error(transparent)]
71    Signer(#[from] SignerError),
72    /// Invalid public key.
73    ///
74    /// Public key is not valid. Not retryable.
75    #[error("Invalid public key")]
76    InvalidPublicKey,
77    /// Invalid client data.
78    ///
79    /// Client data is malformed. Not retryable.
80    #[error("client_data is invalid")]
81    InvalidClientData,
82    /// Alloy signer error.
83    ///
84    /// Ethereum signer failed. Not retryable.
85    #[error(transparent)]
86    SignerError(#[from] alloy::signers::Error),
87    /// Alloy signature error.
88    ///
89    /// Ethereum signature parsing failed. Not retryable.
90    #[error(transparent)]
91    Signature(#[from] alloy::primitives::SignatureError),
92}
93
94impl RetryableError for SignatureError {
95    fn is_retryable(&self) -> bool {
96        match self {
97            // Smart contract wallet verification goes through an RPC provider;
98            // transient provider/IO failures must surface as retryable so the
99            // welcome sync path does not permanently advance the cursor past
100            // welcomes involving SCW users. See xmtp/libxmtp#3394.
101            SignatureError::VerifierError(e) => e.is_retryable(),
102            _ => false,
103        }
104    }
105}
106
107/// Xmtp Installation Credential for Specialized for XMTP Identity
108pub struct InboxIdInstallationCredential;
109
110pub struct InstallationKeyContext;
111pub struct PublicContext;
112
113impl CredentialSign<InboxIdInstallationCredential> for XmtpInstallationCredential {
114    type Error = SignatureError;
115
116    fn credential_sign<T: SigningContextProvider>(
117        &self,
118        text: impl AsRef<str>,
119    ) -> Result<Vec<u8>, Self::Error> {
120        let mut prehashed: Sha512 = Sha512::new();
121        prehashed.update(text.as_ref());
122        let context = self.with_context(T::context())?;
123        let sig = context
124            .try_sign_digest(prehashed)
125            .map_err(SignatureError::from)?;
126        Ok(sig.to_bytes().into())
127    }
128}
129
130impl CredentialVerify<InboxIdInstallationCredential> for ed25519_dalek::VerifyingKey {
131    type Error = SignatureError;
132
133    fn credential_verify<T: SigningContextProvider>(
134        &self,
135        signature_text: impl AsRef<str>,
136        signature_bytes: &[u8; 64],
137    ) -> Result<(), Self::Error> {
138        let signature = Signature::from_bytes(signature_bytes);
139        let mut prehashed = Sha512::new();
140        prehashed.update(signature_text.as_ref());
141        self.verify_prehashed(prehashed, Some(T::context()), &signature)?;
142        Ok(())
143    }
144}
145
146impl SigningContextProvider for InstallationKeyContext {
147    fn context() -> &'static [u8] {
148        crate::constants::INSTALLATION_KEY_SIGNATURE_CONTEXT
149    }
150}
151
152impl SigningContextProvider for PublicContext {
153    fn context() -> &'static [u8] {
154        crate::constants::PUBLIC_SIGNATURE_CONTEXT
155    }
156}
157
158pub fn verify_signed_with_public_context(
159    signature_text: impl AsRef<str>,
160    signature_bytes: &[u8; 64],
161    public_key: &[u8; 32],
162) -> Result<(), SignatureError> {
163    let verifying_key = VerifyingKey::from_bytes(public_key)?;
164    verifying_key.credential_verify::<PublicContext>(signature_text, signature_bytes)
165}
166
167#[derive(Clone, Debug, PartialEq)]
168pub enum SignatureKind {
169    // We might want to have some sort of LegacyErc191 Signature Kind for the `CreateIdentity` signatures only
170    Erc191,
171    Erc1271,
172    InstallationKey,
173    LegacyDelegated,
174    P256,
175}
176
177impl std::fmt::Display for SignatureKind {
178    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
179        match self {
180            SignatureKind::Erc191 => write!(f, "erc-191"),
181            SignatureKind::Erc1271 => write!(f, "erc-1271"),
182            SignatureKind::InstallationKey => write!(f, "installation-key"),
183            SignatureKind::LegacyDelegated => write!(f, "legacy-delegated"),
184            SignatureKind::P256 => write!(f, "p256"),
185        }
186    }
187}
188
189#[derive(Debug, Error, ErrorCode)]
190pub enum AccountIdError {
191    /// Invalid chain ID.
192    ///
193    /// Chain ID is not a valid u64. Not retryable.
194    #[error("Chain ID is not a valid u64")]
195    InvalidChainId,
196    /// Missing EIP-155 prefix.
197    ///
198    /// Chain ID is not prefixed with `eip155:`. Not retryable.
199    #[error("Chain ID is not prefixed with eip155:")]
200    MissingEip155Prefix,
201}
202
203// CAIP-10[https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-10.md]
204#[derive(Debug, Clone, PartialEq)]
205pub struct AccountId {
206    pub(crate) chain_id: String,
207    pub(crate) account_address: String,
208}
209
210impl AccountId {
211    pub fn new(chain_id: String, account_address: String) -> Self {
212        AccountId {
213            chain_id,
214            account_address,
215        }
216    }
217
218    pub fn new_evm(chain_id: u64, account_address: String) -> Self {
219        Self::new(format!("eip155:{}", chain_id), account_address)
220    }
221
222    pub fn is_evm_chain(&self) -> bool {
223        self.chain_id.starts_with("eip155")
224    }
225
226    pub fn get_account_address(&self) -> &str {
227        &self.account_address
228    }
229
230    pub fn get_chain_id(&self) -> &str {
231        &self.chain_id
232    }
233
234    pub fn get_chain_id_u64(&self) -> Result<u64, AccountIdError> {
235        let stripped = self
236            .chain_id
237            .strip_prefix("eip155:")
238            .ok_or(AccountIdError::MissingEip155Prefix)?;
239
240        stripped
241            .parse::<u64>()
242            .map_err(|_| AccountIdError::InvalidChainId)
243    }
244}
245
246/// Decode the `legacy_signed_private_key` to legacy private / public key pairs & sign the `signature_text` with the private key.
247pub fn sign_with_legacy_key(
248    signature_text: String,
249    legacy_signed_private_key: Vec<u8>,
250) -> Result<UnverifiedLegacyDelegatedSignature, SignatureError> {
251    let legacy_signed_private_key_proto =
252        LegacySignedPrivateKeyProto::decode(legacy_signed_private_key.as_slice())?;
253    let signed_private_key::Union::Secp256k1(secp256k1) = legacy_signed_private_key_proto
254        .union
255        .ok_or(SignatureError::MalformedLegacyKey(
256            "Missing secp256k1.union field".to_string(),
257        ))?;
258    let legacy_private_key = secp256k1.bytes;
259    let signer = LocalSigner::from_slice(legacy_private_key.as_slice())?;
260    let signature = signer.sign_message_sync(signature_text.as_bytes())?;
261
262    let legacy_signed_public_key_proto =
263        legacy_signed_private_key_proto
264            .public_key
265            .ok_or(SignatureError::MalformedLegacyKey(
266                "Missing public_key field".to_string(),
267            ))?;
268
269    Ok(UnverifiedLegacyDelegatedSignature::new(
270        UnverifiedRecoverableEcdsaSignature::new(signature.as_bytes().to_vec()),
271        legacy_signed_public_key_proto,
272    ))
273}
274
275#[derive(Clone, Debug)]
276pub struct ValidatedLegacySignedPublicKey {
277    pub(crate) account_address: String,
278    pub(crate) serialized_key_data: Vec<u8>,
279    pub(crate) wallet_signature: VerifiedSignature,
280    pub(crate) public_key_bytes: Vec<u8>,
281    pub(crate) created_ns: u64,
282}
283
284impl ValidatedLegacySignedPublicKey {
285    fn header_text() -> String {
286        let label = "Create Identity".to_string();
287        format!("XMTP : {}", label)
288    }
289
290    fn body_text(serialized_legacy_key: &[u8]) -> String {
291        hex::encode(serialized_legacy_key)
292    }
293
294    fn footer_text() -> String {
295        "For more info: https://xmtp.org/signatures/".to_string()
296    }
297
298    pub fn text(serialized_legacy_key: &[u8]) -> String {
299        format!(
300            "{}\n{}\n\n{}",
301            Self::header_text(),
302            Self::body_text(serialized_legacy_key),
303            Self::footer_text()
304        )
305        .to_string()
306    }
307
308    pub fn account_address(&self) -> String {
309        self.account_address.clone()
310    }
311
312    pub fn key_bytes(&self) -> Vec<u8> {
313        self.public_key_bytes.clone()
314    }
315
316    pub fn created_ns(&self) -> u64 {
317        self.created_ns
318    }
319}
320
321/// Converts a signature to use the lower-s value to prevent signature malleability
322pub fn to_lower_s(sig_bytes: &[u8]) -> Result<Vec<u8>, SignatureError> {
323    // Check if we have a recovery id byte
324    let (sig_data, recovery_id) = match sig_bytes.len() {
325        64 => (sig_bytes, None),                       // No recovery id
326        65 => (&sig_bytes[..64], Some(sig_bytes[64])), // Recovery id present
327        _ => return Err(SignatureError::Invalid),
328    };
329
330    // Parse the signature bytes into a K256Signature
331    let sig = K256Signature::try_from(sig_data)?;
332
333    // If s is already normalized (lower-s), return the original bytes
334    let normalized = match sig.normalize_s() {
335        None => sig_data.to_vec(),
336        Some(normalized) => normalized.to_bytes().to_vec(),
337    };
338
339    // Add back recovery id if it was present
340    if let Some(rid) = recovery_id {
341        let mut result = normalized;
342        result.push(rid);
343        Ok(result)
344    } else {
345        Ok(normalized)
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::SignatureError;
352    use super::to_lower_s;
353    use crate::scw_verifier::VerifierError;
354    use alloy::signers::k256::ecdsa::Signature as K256Signature;
355    use alloy::signers::k256::elliptic_curve::scalar::IsHigh;
356    use alloy::signers::{SignerSync, local::LocalSigner};
357    use wasm_bindgen_test::wasm_bindgen_test;
358    use xmtp_common::RetryableError;
359
360    #[xmtp_common::test]
361    fn test_signature_error_verifier_retryable_propagates() {
362        // Retryable verifier error (NoVerifier) should make SignatureError retryable.
363        let err = SignatureError::VerifierError(VerifierError::NoVerifier("eip155:1".to_string()));
364        assert!(
365            err.is_retryable(),
366            "SignatureError wrapping a retryable VerifierError must be retryable"
367        );
368    }
369
370    #[xmtp_common::test]
371    fn test_signature_error_verifier_non_retryable_propagates() {
372        // Non-retryable verifier error (MalformedEipUrl) should stay non-retryable.
373        let err = SignatureError::VerifierError(VerifierError::MalformedEipUrl);
374        assert!(
375            !err.is_retryable(),
376            "SignatureError wrapping a non-retryable VerifierError must not be retryable"
377        );
378    }
379
380    #[xmtp_common::test]
381    fn test_signature_error_non_verifier_variants_not_retryable() {
382        // Terminal crypto/parse failures are never retryable.
383        assert!(!SignatureError::Invalid.is_retryable());
384        assert!(!SignatureError::InvalidPublicKey.is_retryable());
385        assert!(!SignatureError::InvalidClientData.is_retryable());
386        assert!(!SignatureError::MalformedLegacyKey("missing field".to_string()).is_retryable(),);
387    }
388
389    #[xmtp_common::test]
390    fn test_to_lower_s() {
391        // Create a test wallet
392        let signer = LocalSigner::random();
393
394        // Sign a test message
395        let message = "test message";
396        let signature = signer.sign_message_sync(message.as_bytes()).unwrap();
397        let sig_bytes: Vec<u8> = signature.into();
398
399        // Test normalizing an already normalized signature
400        let normalized = to_lower_s(&sig_bytes).unwrap();
401        assert_eq!(
402            normalized, sig_bytes,
403            "Already normalized signature should not change"
404        );
405
406        // Create a signature with high-s value by manipulating the s component
407        let mut high_s_sig = sig_bytes.clone();
408        // Flip bits in the s component (last 32 bytes) to create a high-s value
409        for byte in high_s_sig[32..64].iter_mut() {
410            *byte = !*byte;
411        }
412
413        // Normalize the manipulated signature
414        let normalized_high_s = to_lower_s(&high_s_sig).unwrap();
415        assert_ne!(
416            normalized_high_s, high_s_sig,
417            "High-s signature should be normalized"
418        );
419
420        // Verify the normalized signature is valid
421        let recovered_sig = K256Signature::try_from(&normalized_high_s.as_slice()[..64]).unwrap();
422        let is_high: bool = recovered_sig.s().is_high().into();
423        assert!(!is_high, "Normalized signature should have low-s value");
424    }
425
426    #[wasm_bindgen_test(unsupported = test)]
427    fn test_invalid_signature() {
428        // Test with invalid signature bytes
429        let invalid_sig = vec![0u8; 65];
430        let result = to_lower_s(&invalid_sig);
431        assert!(result.is_err(), "Should fail with invalid signature");
432
433        // Test with wrong length
434        let wrong_length = vec![0u8; 63];
435        let result = to_lower_s(&wrong_length);
436        assert!(result.is_err(), "Should fail with wrong length");
437    }
438}