Skip to content

Commit 8c182f5

Browse files
authored
Accept EcdsaCompact signatures for legacy keys (#508)
Changes include: - Update example app to use v2 legacy signing flow - Update error messages in libxmtp to be more descriptive, as well as add more logging - Prefix libxmtp logs with `[libxmtp]` to make filtering easier - Accept `EcdsaCompact` signatures for legacy keys in addition to the existing `WalletEcdsaCompact` I'm not sure if there is an interop bug in Android somewhere, but it's better for Rust to just be permissive with the signatures here, and we can loop back if there is truly an issue in Android. _____ My notes on `EcdsaCompact` for posterity: it looks like JS will throw if WalletEcdsaCompact is not set: https://github.com/xmtp/xmtp-js/blob/main/src/crypto/PublicKey.ts#L145. However, they map ecdsaCompact from the legacy key to walletEcdsaCompact when translating from PrivateKeyBundleV1 to a PrivateKeyBundleV2 : https://github.com/xmtp/xmtp-js/blob/main/src/crypto/PublicKey.ts#L204 I think the bug in Android is the translation step from V1 to V2, which is copying the old signature as-is: https://github.com/xmtp/xmtp-android/blob/edd7eb27dd30a557be6ef81f0a4221c5ebfbb259/library/src/main/java/org/xmtp/android/library/messages/SignedPrivateKey.kt#L17 I'm not sure whether this breaks anything in practice, because I'm not sure whether Android uploads the resulting V2 bundle to the network afterwards, or just consumes it locally on the client. ____ Also for the V2 bundles, it seems we're leaving it up to the integrator to implement SigningKey: https://github.com/xmtp/xmtp-android?tab=readme-ov-file#create-a-client The SigningKey interface basically leaves it up to them whether they set EcdsaCompact or WalletEcdsaCompact, for example this is a SigningKey implementation: https://github.com/xmtp/xmtp-android/blob/edd7eb27dd30a557be6ef81f0a4221c5ebfbb259/library/src/main/java/org/xmtp/android/library/messages/PrivateKey.kt#L88 I'm not sure whether or not the SDK gracefully handles that case
1 parent f37b2a6 commit 8c182f5

File tree

13 files changed

+149
-56
lines changed

13 files changed

+149
-56
lines changed

bindings_ffi/examples/MainActivity.kt

+53-25
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,24 @@ import android.util.Log
55
import android.widget.TextView
66
import androidx.appcompat.app.AppCompatActivity
77
import com.example.xmtpv3_example.R.id.selftest_output
8+
import java.io.File
9+
import java.nio.charset.StandardCharsets
10+
import java.security.SecureRandom
811
import kotlinx.coroutines.runBlocking
912
import org.bouncycastle.util.encoders.Hex.toHexString
1013
import org.web3j.crypto.Credentials
1114
import org.web3j.crypto.ECKeyPair
1215
import org.web3j.crypto.Sign
16+
import org.xmtp.android.library.Client
17+
import org.xmtp.android.library.ClientOptions
18+
import org.xmtp.android.library.XMTPEnvironment
19+
import org.xmtp.android.library.messages.PrivateKeyBuilder
20+
import org.xmtp.android.library.messages.toV2
1321
import uniffi.xmtpv3.FfiConversationCallback
1422
import uniffi.xmtpv3.FfiGroup
1523
import uniffi.xmtpv3.FfiInboxOwner
1624
import uniffi.xmtpv3.FfiLogger
1725
import uniffi.xmtpv3.LegacyIdentitySource
18-
import java.io.File
19-
import java.nio.charset.StandardCharsets
20-
import java.security.SecureRandom
2126

2227
const val EMULATOR_LOCALHOST_ADDRESS = "http://10.0.2.2:5556"
2328
const val DEV_NETWORK_ADDRESS = "https://dev.xmtp.network:5556"
@@ -40,9 +45,15 @@ class AndroidFfiLogger : FfiLogger {
4045
}
4146
}
4247

43-
class ConversationCallback: FfiConversationCallback {
48+
class ConversationCallback : FfiConversationCallback {
4449
override fun onConversation(conversation: FfiGroup) {
45-
Log.i("App", "INFO - Conversation callback with ID: " + toHexString(conversation.id()) + ", members: " + conversation.listMembers())
50+
Log.i(
51+
"App",
52+
"INFO - Conversation callback with ID: " +
53+
toHexString(conversation.id()) +
54+
", members: " +
55+
conversation.listMembers()
56+
)
4657
}
4758
}
4859

@@ -54,44 +65,61 @@ class MainActivity : AppCompatActivity() {
5465
setContentView(R.layout.activity_main)
5566

5667
val textView: TextView = findViewById<TextView>(selftest_output)
57-
val privateKey: ByteArray = SecureRandom().generateSeed(32)
58-
val credentials: Credentials = Credentials.create(ECKeyPair.create(privateKey))
59-
val inboxOwner = Web3jInboxOwner(credentials)
6068
val dbDir: File = File(this.filesDir.absolutePath, "xmtp_db")
69+
try {
70+
dbDir.deleteRecursively()
71+
} catch (e: Exception) {}
6172
dbDir.mkdir()
6273
val dbPath: String = dbDir.absolutePath + "/android_example.db3"
6374
val dbEncryptionKey = SecureRandom().generateSeed(32)
6475
Log.i(
65-
"App",
66-
"INFO -\naccountAddress: " + inboxOwner.getAddress() + "\nprivateKey: " + privateKey.asList() + "\nDB path: " + dbPath + "\nDB encryption key: " + dbEncryptionKey
76+
"App",
77+
"INFO -\nDB path: " +
78+
dbPath +
79+
"\nDB encryption key: " +
80+
dbEncryptionKey
6781
)
6882

6983
runBlocking {
7084
try {
71-
val client = uniffi.xmtpv3.createClient(
72-
AndroidFfiLogger(),
73-
EMULATOR_LOCALHOST_ADDRESS,
74-
false,
75-
dbPath,
76-
dbEncryptionKey,
77-
inboxOwner.getAddress(),
78-
LegacyIdentitySource.NONE,
79-
null,
80-
)
81-
var walletSignature: ByteArray? = null;
82-
val textToSign = client.textToSign();
85+
val key = PrivateKeyBuilder()
86+
val client =
87+
uniffi.xmtpv3.createClient(
88+
AndroidFfiLogger(),
89+
EMULATOR_LOCALHOST_ADDRESS,
90+
false,
91+
dbPath,
92+
dbEncryptionKey,
93+
key.address,
94+
LegacyIdentitySource.KEY_GENERATOR,
95+
getV2SerializedSignedPrivateKey(key),
96+
)
97+
var walletSignature: ByteArray? = null
98+
val textToSign = client.textToSign()
8399
if (textToSign != null) {
84-
walletSignature = inboxOwner.sign(textToSign)
100+
walletSignature = key.sign(textToSign).toByteArray()
85101
}
86102
client.registerIdentity(walletSignature);
87103
textView.text = "Libxmtp version\n" + uniffi.xmtpv3.getVersionInfo() + "\n\nClient constructed, wallet address: " + client.accountAddress()
88104
Log.i("App", "Setting up conversation streaming")
89-
client.conversations().stream(ConversationCallback());
105+
client.conversations().stream(ConversationCallback())
90106
} catch (e: Exception) {
91107
textView.text = "Failed to construct client: " + e.message
92108
}
93109
}
110+
}
94111

95-
dbDir.deleteRecursively()
112+
fun getV2SerializedSignedPrivateKey(key: PrivateKeyBuilder): ByteArray {
113+
val options =
114+
ClientOptions(
115+
api =
116+
ClientOptions.Api(
117+
env = XMTPEnvironment.LOCAL,
118+
isSecure = false,
119+
),
120+
appContext = this@MainActivity
121+
)
122+
val client = Client().create(account = key, options = options)
123+
return client.privateKeyBundleV1.toV2().identityKey.toByteArray();
96124
}
97125
}
Binary file not shown.

bindings_ffi/src/logger.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ impl log::Log for RustLogger {
2020
self.logger.lock().expect("Logger mutex is poisoned!").log(
2121
record.level() as u32,
2222
record.level().to_string(),
23-
record.args().to_string(),
23+
format!("[libxmtp] {}", record.args().to_string()),
2424
);
2525
}
2626
}

bindings_ffi/src/mls.rs

+35
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,41 @@ mod tests {
616616
assert!(!client.account_address().is_empty());
617617
}
618618

619+
#[tokio::test]
620+
async fn test_legacy_identity() {
621+
let legacy_address = "0x419cb1fa5635b0c6df47c9dc5765c8f1f4dff78e";
622+
let legacy_signed_private_key_proto = vec![
623+
8, 128, 154, 196, 133, 220, 244, 197, 216, 23, 18, 34, 10, 32, 214, 70, 104, 202, 68,
624+
204, 25, 202, 197, 141, 239, 159, 145, 249, 55, 242, 147, 126, 3, 124, 159, 207, 96,
625+
135, 134, 122, 60, 90, 82, 171, 131, 162, 26, 153, 1, 10, 79, 8, 128, 154, 196, 133,
626+
220, 244, 197, 216, 23, 26, 67, 10, 65, 4, 232, 32, 50, 73, 113, 99, 115, 168, 104,
627+
229, 206, 24, 217, 132, 223, 217, 91, 63, 137, 136, 50, 89, 82, 186, 179, 150, 7, 127,
628+
140, 10, 165, 117, 233, 117, 196, 134, 227, 143, 125, 210, 187, 77, 195, 169, 162, 116,
629+
34, 20, 196, 145, 40, 164, 246, 139, 197, 154, 233, 190, 148, 35, 131, 240, 106, 103,
630+
18, 70, 18, 68, 10, 64, 90, 24, 36, 99, 130, 246, 134, 57, 60, 34, 142, 165, 221, 123,
631+
63, 27, 138, 242, 195, 175, 212, 146, 181, 152, 89, 48, 8, 70, 104, 94, 163, 0, 25,
632+
196, 228, 190, 49, 108, 141, 60, 174, 150, 177, 115, 229, 138, 92, 105, 170, 226, 204,
633+
249, 206, 12, 37, 145, 3, 35, 226, 15, 49, 20, 102, 60, 16, 1,
634+
];
635+
636+
let client = create_client(
637+
Box::new(MockLogger {}),
638+
xmtp_api_grpc::LOCALHOST_ADDRESS.to_string(),
639+
false,
640+
Some(tmp_path()),
641+
None,
642+
legacy_address.to_string(),
643+
LegacyIdentitySource::KeyGenerator,
644+
Some(legacy_signed_private_key_proto),
645+
)
646+
.await
647+
.unwrap();
648+
649+
assert!(client.text_to_sign().is_none());
650+
client.register_identity(None).await.unwrap();
651+
assert_eq!(client.account_address(), legacy_address);
652+
}
653+
619654
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
620655
async fn test_create_client_with_storage() {
621656
let ffi_inbox_owner = LocalWalletInboxOwner::new();

examples/android/xmtpv3_example/app/build.gradle

+2-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ android {
99

1010
defaultConfig {
1111
applicationId "com.example.xmtpv3_example"
12-
minSdk 21
12+
minSdk 23
1313
targetSdk 33
1414
versionCode 1
1515
versionName "1.0"
@@ -37,6 +37,7 @@ dependencies {
3737
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
3838
implementation 'androidx.core:core-ktx:1.7.0'
3939
implementation 'com.google.android.material:material:1.8.0'
40+
implementation "org.xmtp:android:0.7.10"
4041
implementation "net.java.dev.jna:jna:5.13.0@aar"
4142
implementation 'org.web3j:crypto:5.0.0'
4243
testImplementation 'junit:junit:4.13.2'
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Top-level build file where you can add configuration options common to all sub-projects/modules.
22
plugins {
3-
id 'com.android.application' version '7.3.1' apply false
4-
id 'com.android.library' version '7.3.1' apply false
3+
id 'com.android.application' version '8.0.0-rc01' apply false
4+
id 'com.android.library' version '8.0.0-rc01' apply false
55
id 'org.jetbrains.kotlin.android' version '1.7.20' apply false
66
}

examples/android/xmtpv3_example/gradle.properties

+3-1
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,6 @@ kotlin.code.style=official
2020
# Enables namespacing of each library's R class so that its R class includes only the
2121
# resources declared in the library itself and none from the library's dependencies,
2222
# thereby reducing the size of the R class for that library
23-
android.nonTransitiveRClass=true
23+
android.nonTransitiveRClass=true
24+
android.defaults.buildfeatures.buildconfig=true
25+
android.nonFinalResIds=false
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#Wed Mar 29 17:13:20 PDT 2023
22
distributionBase=GRADLE_USER_HOME
3-
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip
3+
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip
44
distributionPath=wrapper/dists
55
zipStorePath=wrapper/dists
66
zipStoreBase=GRADLE_USER_HOME

xmtp_mls/src/builder.rs

+4-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use std::println as debug;
33

44
#[cfg(not(test))]
55
use log::debug;
6+
use log::info;
67
use thiserror::Error;
78

89
use xmtp_proto::api_client::XmtpMlsClient;
@@ -38,7 +39,7 @@ pub enum ClientBuilderError {
3839
// AssociationFailed(#[from] AssociationError),
3940
// #[error("Error Initializing Store")]
4041
// StoreInitialization(#[from] SE),
41-
#[error("Error Initalizing Identity")]
42+
#[error("Error initializing identity: {0}")]
4243
IdentityInitialization(#[from] IdentityError),
4344

4445
#[error("Storage Error")]
@@ -82,6 +83,7 @@ impl IdentityStrategy {
8283
api_client: &ApiClientWrapper<ApiClient>,
8384
store: &EncryptedMessageStore,
8485
) -> Result<Identity, ClientBuilderError> {
86+
info!("Initializing identity");
8587
let conn = store.conn()?;
8688
let provider = XmtpOpenMlsProvider::new(&conn);
8789
let identity_option: Option<Identity> = provider
@@ -117,6 +119,7 @@ impl IdentityStrategy {
117119
account_address: String,
118120
legacy_identity: LegacyIdentity,
119121
) -> Result<Identity, ClientBuilderError> {
122+
info!("Creating identity");
120123
let identity = match legacy_identity {
121124
// This is a fresh install, and at most one v2 signature (enable_identity)
122125
// has been requested so far, so it's fine to request another one (grant_messaging_access).

xmtp_mls/src/credential/legacy_create_identity_association.rs

+6-4
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ impl LegacyCreateIdentityAssociation {
4242
LegacySignedPrivateKeyProto::decode(legacy_signed_private_key.as_slice())?;
4343
let signed_private_key::Union::Secp256k1(secp256k1) = legacy_signed_private_key_proto
4444
.union
45-
.ok_or(AssociationError::MalformedLegacyKey)?;
45+
.ok_or(AssociationError::MalformedLegacyKey(
46+
"Missing secp256k1.union field".to_string(),
47+
))?;
4648
let legacy_private_key = secp256k1.bytes;
4749
let (mut delegating_signature, recovery_id) = k256_helper::sign_sha256(
4850
&legacy_private_key, // secret_key
@@ -51,9 +53,9 @@ impl LegacyCreateIdentityAssociation {
5153
.map_err(AssociationError::LegacySignature)?;
5254
delegating_signature.push(recovery_id); // TODO: normalize recovery ID if necessary
5355

54-
let legacy_signed_public_key_proto = legacy_signed_private_key_proto
55-
.public_key
56-
.ok_or(AssociationError::MalformedLegacyKey)?;
56+
let legacy_signed_public_key_proto = legacy_signed_private_key_proto.public_key.ok_or(
57+
AssociationError::MalformedLegacyKey("Missing public_key field".to_string()),
58+
)?;
5759
Self::new_validated(
5860
installation_public_key,
5961
delegating_signature,

xmtp_mls/src/credential/mod.rs

+3-3
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,10 @@ use self::legacy_create_identity_association::LegacyCreateIdentityAssociation;
2525
pub enum AssociationError {
2626
#[error("bad signature")]
2727
BadSignature(#[from] SignatureError),
28-
#[error("decode error")]
28+
#[error("decode error: {0}")]
2929
DecodeError(#[from] DecodeError),
30-
#[error("legacy key")]
31-
MalformedLegacyKey,
30+
#[error("legacy key: {0}")]
31+
MalformedLegacyKey(String),
3232
#[error("legacy signature: {0}")]
3333
LegacySignature(String),
3434
#[error("Association text mismatch")]

xmtp_mls/src/credential/validated_legacy_signed_public_key.rs

+27-10
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use log::info;
12
use prost::Message;
23

34
use xmtp_cryptography::signature::RecoverableSignature;
@@ -61,19 +62,35 @@ impl TryFrom<LegacySignedPublicKeyProto> for ValidatedLegacySignedPublicKey {
6162

6263
fn try_from(proto: LegacySignedPublicKeyProto) -> Result<Self, AssociationError> {
6364
let serialized_key_data = proto.key_bytes;
64-
let Union::WalletEcdsaCompact(wallet_ecdsa_compact) = proto
65+
let union = proto
6566
.signature
66-
.ok_or(AssociationError::MalformedLegacyKey)?
67+
.ok_or(AssociationError::MalformedLegacyKey(
68+
"Missing signature field".to_string(),
69+
))?
6770
.union
68-
.ok_or(AssociationError::MalformedLegacyKey)?
69-
else {
70-
return Err(AssociationError::MalformedLegacyKey);
71+
.ok_or(AssociationError::MalformedLegacyKey(
72+
"Missing signature.union field".to_string(),
73+
))?;
74+
let wallet_signature = match union {
75+
Union::WalletEcdsaCompact(wallet_ecdsa_compact) => {
76+
info!("Reading WalletEcdsaCompact from legacy key");
77+
let mut wallet_signature = wallet_ecdsa_compact.bytes.clone();
78+
wallet_signature.push(wallet_ecdsa_compact.recovery as u8); // TODO: normalize recovery ID if necessary
79+
if wallet_signature.len() != 65 {
80+
return Err(AssociationError::MalformedAssociation);
81+
}
82+
wallet_signature
83+
}
84+
Union::EcdsaCompact(ecdsa_compact) => {
85+
info!("Reading EcdsaCompact from legacy key");
86+
let mut signature = ecdsa_compact.bytes.clone();
87+
signature.push(ecdsa_compact.recovery as u8); // TODO: normalize recovery ID if necessary
88+
if signature.len() != 65 {
89+
return Err(AssociationError::MalformedAssociation);
90+
}
91+
signature
92+
}
7193
};
72-
let mut wallet_signature = wallet_ecdsa_compact.bytes.clone();
73-
wallet_signature.push(wallet_ecdsa_compact.recovery as u8); // TODO: normalize recovery ID if necessary
74-
if wallet_signature.len() != 65 {
75-
return Err(AssociationError::MalformedAssociation);
76-
}
7794
let wallet_signature = RecoverableSignature::Eip191Signature(wallet_signature);
7895
let account_address =
7996
wallet_signature.recover_address(&Self::text(&serialized_key_data))?;

0 commit comments

Comments
 (0)