Skip to main content

xmtp_mls/groups/
commit_log_key.rs

1use crate::groups::MlsGroup;
2use crate::groups::XmtpSharedContext;
3use crate::groups::commit_log::CommitLogError;
4use openmls::prelude::{OpenMlsCrypto, SignatureScheme};
5use openmls_rust_crypto::RustCrypto;
6use openmls_traits::OpenMlsProvider;
7use xmtp_cryptography::Secret;
8use xmtp_db::MlsProviderExt;
9use xmtp_db::group::StoredGroupCommitLogPublicKey;
10use xmtp_db::prelude::QueryGroup;
11use xmtp_db::{
12    XmtpMlsStorageProvider,
13    sql_key_store::{COMMIT_LOG_SIGNER_PRIVATE_KEY, SqlKeyStoreError},
14};
15use xmtp_proto::backend_v1::CommitLogEntry as CommitLogEntryProto;
16
17use xmtp_proto::types::GroupId;
18pub(crate) trait CommitLogKeyCrypto {
19    type Error: std::error::Error;
20    fn generate_commit_log_key(&self) -> Result<Secret, Self::Error>;
21    fn public_key_matches_private_key(public_key: &[u8], private_key: &Secret) -> bool;
22    fn verify_commit_log_signature(
23        &self,
24        entry: &CommitLogEntryProto,
25        expected_public_key: &[u8],
26    ) -> Result<(), Self::Error>;
27}
28
29impl CommitLogKeyCrypto for RustCrypto {
30    type Error = openmls_traits::types::CryptoError;
31    fn generate_commit_log_key(&self) -> Result<Secret, Self::Error> {
32        let (private_key, _) = self.signature_key_gen(SignatureScheme::ED25519)?;
33        Ok(Secret::new(private_key))
34    }
35
36    fn public_key_matches_private_key(public_key: &[u8], private_key: &Secret) -> bool {
37        let Ok(computed_public_key) = xmtp_cryptography::signature::to_public_key(private_key)
38        else {
39            tracing::warn!("Invalid private key length");
40            return false;
41        };
42        public_key == computed_public_key
43    }
44
45    fn verify_commit_log_signature(
46        &self,
47        entry: &CommitLogEntryProto,
48        expected_public_key: &[u8],
49    ) -> Result<(), Self::Error> {
50        let Some(signature) = &entry.signature else {
51            return Err(openmls_traits::types::CryptoError::InvalidSignature);
52        };
53        if signature.public_key != expected_public_key {
54            return Err(openmls_traits::types::CryptoError::InvalidSignature);
55        }
56        self.verify_signature(
57            SignatureScheme::ED25519,
58            entry.serialized_commit_log_entry.as_slice(),
59            expected_public_key,
60            &signature.bytes,
61        )?;
62        Ok(())
63    }
64}
65
66pub(crate) trait CommitLogKeyStore {
67    type Error: std::error::Error;
68    fn read_commit_log_key(
69        &self,
70        group_id: impl AsRef<[u8]>,
71    ) -> Result<Option<Secret>, Self::Error>;
72    fn write_commit_log_key(
73        &self,
74        group_id: impl AsRef<[u8]>,
75        value: &Secret,
76    ) -> Result<(), Self::Error>;
77}
78
79impl<KeyStore: XmtpMlsStorageProvider> CommitLogKeyStore for KeyStore {
80    type Error = SqlKeyStoreError;
81
82    fn read_commit_log_key(
83        &self,
84        group_id: impl AsRef<[u8]>,
85    ) -> Result<Option<Secret>, Self::Error> {
86        let key = bincode::serialize(group_id.as_ref())?;
87        let value = self
88            .read::<Vec<u8>>(COMMIT_LOG_SIGNER_PRIVATE_KEY, &key)?
89            .map(Secret::new);
90        Ok(value)
91    }
92
93    fn write_commit_log_key(
94        &self,
95        group_id: impl AsRef<[u8]>,
96        value: &Secret,
97    ) -> Result<(), Self::Error> {
98        let key = bincode::serialize(group_id.as_ref())?;
99        let value = Secret::new(bincode::serialize(value.as_slice())?);
100        self.write(COMMIT_LOG_SIGNER_PRIVATE_KEY, &key, value.as_slice())
101    }
102}
103
104pub(crate) async fn maybe_share_private_key(
105    context: &impl XmtpSharedContext,
106    group_id: &GroupId,
107    consensus_public_key: &[u8],
108) -> Result<(), CommitLogError> {
109    let provider = context.mls_provider();
110    if let Some(stored_private_key) = provider.key_store().read_commit_log_key(group_id)?
111        && RustCrypto::public_key_matches_private_key(consensus_public_key, &stored_private_key)
112    {
113        let (group, _) = MlsGroup::new_cached(context, group_id)?;
114        if group.dm_id.is_some() {
115            // We cannot update mutable metadata for DMs
116            return Ok(());
117        }
118        let metadata = group.mutable_metadata()?;
119        if metadata.commit_log_signer().is_none_or(|private_key| {
120            !RustCrypto::public_key_matches_private_key(consensus_public_key, &private_key)
121        }) {
122            group.update_commit_log_signer(stored_private_key).await?;
123        }
124    }
125    Ok(())
126}
127
128pub(crate) async fn derive_consensus_public_key(
129    context: &impl XmtpSharedContext,
130    group_id: &[u8],
131    entries: &[CommitLogEntryProto],
132) -> Result<Option<Vec<u8>>, CommitLogError> {
133    let provider = context.mls_provider();
134    let group_id = GroupId::try_from(group_id)?;
135    // Find the first entry with a valid signature and extract its public key
136    for entry in entries {
137        if let Some(signature) = &entry.signature
138            && provider
139                .crypto()
140                .verify_commit_log_signature(entry, &signature.public_key)
141                .is_ok()
142        {
143            maybe_share_private_key(context, &group_id, &signature.public_key).await?;
144            context
145                .db()
146                .set_group_commit_log_public_key(&group_id, &signature.public_key)?;
147            return Ok(Some(signature.public_key.clone()));
148        }
149    }
150
151    tracing::warn!(
152        "No valid signature found in commit log response for group {:?}",
153        hex::encode(group_id)
154    );
155    Ok(None)
156}
157
158pub(crate) fn get_or_create_signing_key(
159    context: &impl XmtpSharedContext,
160    conversation: &StoredGroupCommitLogPublicKey,
161) -> Result<Option<Secret>, CommitLogError> {
162    let provider = context.mls_provider();
163    let key_store = provider.key_store();
164    // The consensus_public_key is derived from the first entry in the commit log, if one has been previously received.
165    // If there is one, we try to find the private key in the key store, and then the mutable metadata, returning None if not found.
166    // If there is none, we use any existing private key from the same locations, creating a new key if not found.
167    let consensus_public_key = conversation.commit_log_public_key.as_ref();
168
169    if let Some(private_key) = key_store.read_commit_log_key(conversation.id)?
170        && consensus_public_key.is_none_or(|consensus_public_key| {
171            RustCrypto::public_key_matches_private_key(consensus_public_key, &private_key)
172        })
173    {
174        return Ok(Some(private_key));
175    }
176
177    let (group, _) = MlsGroup::new_cached(context, &conversation.id)?;
178    if let Some(private_key) = group.mutable_metadata()?.commit_log_signer()
179        && consensus_public_key.is_none_or(|consensus_public_key| {
180            RustCrypto::public_key_matches_private_key(consensus_public_key, &private_key)
181        })
182    {
183        key_store.write_commit_log_key(conversation.id, &private_key)?;
184        return Ok(Some(private_key));
185    }
186
187    if consensus_public_key.is_none() {
188        // We have not yet seen an agreed upon public key for this conversation, so we generate a new key.
189        // We store the key locally, but do not share it via mutable metadata until we verify that we
190        // published it as the first commit log entry.
191        let private_key = provider.crypto().generate_commit_log_key()?;
192        key_store.write_commit_log_key(conversation.id, &private_key)?;
193        return Ok(Some(private_key));
194    }
195
196    tracing::warn!(
197        "Commit log consensus key {:?} is not available yet for conversation {:?}",
198        consensus_public_key.map(hex::encode),
199        hex::encode(conversation.id)
200    );
201    Ok(None)
202}
203
204#[cfg(test)]
205mod tests {
206    use xmtp_db::MlsProviderExt;
207    use xmtp_proto::backend_v1::CommitLogEntry as CommitLogEntryProto;
208
209    use super::*;
210    use crate::tester;
211
212    #[xmtp_common::test(unwrap_try = true)]
213    async fn test_read_write_commit_log_key() {
214        tester!(alix);
215        let provider = alix.context.mls_provider();
216        let key_store = provider.key_store();
217
218        key_store.write_commit_log_key([1u8; 32], &Secret::new(vec![10u8; 32]))?;
219
220        // Query on a value that hasn't been written
221        let result = key_store.read_commit_log_key([2u8; 32]);
222        assert!(result.is_ok(), "{}", result.err().unwrap());
223        assert!(result.unwrap().is_none());
224
225        let result = key_store.read_commit_log_key([1u8; 32]);
226        assert!(result.is_ok(), "{}", result.err().unwrap());
227        assert_eq!(result.unwrap().unwrap().as_slice(), &[10u8; 32]);
228    }
229
230    #[xmtp_common::test(unwrap_try = true)]
231    async fn test_verify_commit_log_signature() {
232        tester!(alix);
233        let provider = alix.context.mls_provider();
234        let crypto = provider.crypto();
235
236        let private_key = crypto.generate_commit_log_key().unwrap();
237        let public_key = xmtp_cryptography::signature::to_public_key(&private_key)
238            .unwrap()
239            .to_vec();
240
241        let message = b"test message";
242        let signature_bytes = crypto
243            .sign(
244                openmls::prelude::SignatureScheme::ED25519,
245                message,
246                private_key.as_slice(),
247            )
248            .unwrap();
249
250        let commit_entry = CommitLogEntryProto {
251            serialized_commit_log_entry: message.to_vec(),
252            signature: Some(
253                xmtp_proto::xmtp::identity::associations::RecoverableEd25519Signature {
254                    public_key: public_key.clone(),
255                    bytes: signature_bytes,
256                },
257            ),
258        };
259
260        // Valid signature should verify
261        assert!(
262            crypto
263                .verify_commit_log_signature(&commit_entry, &public_key)
264                .is_ok()
265        );
266
267        // Wrong public key should fail
268        let wrong_public_key = vec![0u8; 32];
269        assert!(
270            crypto
271                .verify_commit_log_signature(&commit_entry, &wrong_public_key)
272                .is_err()
273        );
274
275        // Entry without signature should fail
276        let unsigned_entry = CommitLogEntryProto {
277            serialized_commit_log_entry: message.to_vec(),
278            signature: None,
279        };
280        assert!(
281            crypto
282                .verify_commit_log_signature(&unsigned_entry, &public_key)
283                .is_err()
284        );
285    }
286
287    #[xmtp_common::test(unwrap_try = true)]
288    async fn test_derive_consensus_public_key_with_valid_signature() {
289        tester!(alix);
290        let provider = alix.context.mls_provider();
291        let crypto = provider.crypto();
292
293        // Use an actual group ID to avoid database conflicts
294        let group = alix.create_group(None, None).unwrap();
295
296        // Create first key pair (this should be chosen as consensus key)
297        let first_private_key = crypto.generate_commit_log_key().unwrap();
298        let first_public_key = xmtp_cryptography::signature::to_public_key(&first_private_key)
299            .unwrap()
300            .to_vec();
301
302        // Create second key pair (this should be ignored)
303        let second_private_key = crypto.generate_commit_log_key().unwrap();
304        let second_public_key = xmtp_cryptography::signature::to_public_key(&second_private_key)
305            .unwrap()
306            .to_vec();
307
308        let first_message = b"first commit";
309        let first_signature = crypto
310            .sign(
311                openmls::prelude::SignatureScheme::ED25519,
312                first_message,
313                first_private_key.as_slice(),
314            )
315            .unwrap();
316
317        let second_message = b"second commit";
318        let second_signature = crypto
319            .sign(
320                openmls::prelude::SignatureScheme::ED25519,
321                second_message,
322                second_private_key.as_slice(),
323            )
324            .unwrap();
325
326        let first_entry = CommitLogEntryProto {
327            serialized_commit_log_entry: first_message.to_vec(),
328            signature: Some(
329                xmtp_proto::xmtp::identity::associations::RecoverableEd25519Signature {
330                    public_key: first_public_key.clone(),
331                    bytes: first_signature,
332                },
333            ),
334        };
335
336        let second_entry = CommitLogEntryProto {
337            serialized_commit_log_entry: second_message.to_vec(),
338            signature: Some(
339                xmtp_proto::xmtp::identity::associations::RecoverableEd25519Signature {
340                    public_key: second_public_key.clone(),
341                    bytes: second_signature,
342                },
343            ),
344        };
345
346        let entries = vec![first_entry, second_entry];
347
348        let result =
349            derive_consensus_public_key(&alix.context, group.group_id.as_slice(), &entries)
350                .await
351                .unwrap();
352        assert!(result.is_some());
353        let consensus_key = result.unwrap();
354        // Should return the FIRST valid public key, not the second
355        assert_eq!(consensus_key, first_public_key);
356        assert_ne!(consensus_key, second_public_key);
357    }
358
359    #[xmtp_common::test(unwrap_try = true)]
360    async fn test_derive_consensus_public_key_with_no_valid_signature() {
361        tester!(alix);
362        let provider = alix.context.mls_provider();
363        let crypto = provider.crypto();
364
365        // Use an actual group ID to avoid database conflicts
366        let group = alix.create_group(None, None).unwrap();
367
368        // Create a valid second entry
369        let valid_private_key = crypto.generate_commit_log_key().unwrap();
370        let valid_public_key = xmtp_cryptography::signature::to_public_key(&valid_private_key)
371            .unwrap()
372            .to_vec();
373
374        let valid_message = b"valid commit";
375        let valid_signature = crypto
376            .sign(
377                openmls::prelude::SignatureScheme::ED25519,
378                valid_message,
379                valid_private_key.as_slice(),
380            )
381            .unwrap();
382
383        // First entry has no signature (should be skipped)
384        let unsigned_entry = CommitLogEntryProto {
385            serialized_commit_log_entry: b"unsigned commit".to_vec(),
386            signature: None,
387        };
388
389        // Second entry has valid signature (should be used)
390        let valid_entry = CommitLogEntryProto {
391            serialized_commit_log_entry: valid_message.to_vec(),
392            signature: Some(
393                xmtp_proto::xmtp::identity::associations::RecoverableEd25519Signature {
394                    public_key: valid_public_key.clone(),
395                    bytes: valid_signature,
396                },
397            ),
398        };
399
400        let entries = vec![unsigned_entry, valid_entry];
401
402        let result =
403            derive_consensus_public_key(&alix.context, group.group_id.as_slice(), &entries)
404                .await
405                .unwrap();
406        assert!(result.is_some());
407        // Should derive from the second entry (first valid one)
408        assert_eq!(result.unwrap(), valid_public_key);
409    }
410
411    #[xmtp_common::test(unwrap_try = true)]
412    async fn test_derive_consensus_public_key_with_invalid_signature() {
413        tester!(alix);
414        let provider = alix.context.mls_provider();
415        let crypto = provider.crypto();
416
417        // Use an actual group ID to avoid database conflicts
418        let group = alix.create_group(None, None).unwrap();
419
420        // Create keys for invalid first entry
421        let invalid_private_key = crypto.generate_commit_log_key().unwrap();
422        let invalid_public_key = xmtp_cryptography::signature::to_public_key(&invalid_private_key)
423            .unwrap()
424            .to_vec();
425
426        // Create valid second entry
427        let valid_private_key = crypto.generate_commit_log_key().unwrap();
428        let valid_public_key = xmtp_cryptography::signature::to_public_key(&valid_private_key)
429            .unwrap()
430            .to_vec();
431
432        let valid_message = b"valid commit";
433        let valid_signature = crypto
434            .sign(
435                openmls::prelude::SignatureScheme::ED25519,
436                valid_message,
437                valid_private_key.as_slice(),
438            )
439            .unwrap();
440
441        // First entry with invalid signature (should be skipped)
442        let invalid_entry = CommitLogEntryProto {
443            serialized_commit_log_entry: b"invalid commit".to_vec(),
444            signature: Some(
445                xmtp_proto::xmtp::identity::associations::RecoverableEd25519Signature {
446                    public_key: invalid_public_key.clone(),
447                    bytes: vec![0u8; 64], // Invalid signature bytes
448                },
449            ),
450        };
451
452        // Second entry with valid signature (should be used)
453        let valid_entry = CommitLogEntryProto {
454            serialized_commit_log_entry: valid_message.to_vec(),
455            signature: Some(
456                xmtp_proto::xmtp::identity::associations::RecoverableEd25519Signature {
457                    public_key: valid_public_key.clone(),
458                    bytes: valid_signature,
459                },
460            ),
461        };
462
463        let entries = vec![invalid_entry, valid_entry];
464
465        let result =
466            derive_consensus_public_key(&alix.context, group.group_id.as_slice(), &entries)
467                .await
468                .unwrap();
469        assert!(result.is_some());
470        let consensus_key = result.unwrap();
471        // Should derive from the second entry (first valid one), not the invalid first one
472        assert_eq!(consensus_key, valid_public_key);
473        assert_ne!(consensus_key, invalid_public_key);
474    }
475
476    #[xmtp_common::test(unwrap_try = true)]
477    async fn test_get_or_create_signing_key_uses_mutable_metadata() {
478        tester!(alix);
479
480        // Create a group - this will have a commit_log_signer in mutable metadata by default
481        let group = alix.create_group(None, None).unwrap();
482        let metadata = group.mutable_metadata().unwrap();
483        let mutable_metadata_key = metadata.commit_log_signer().unwrap();
484
485        let conversation = StoredGroupCommitLogPublicKey {
486            id: group.group_id,
487            commit_log_public_key: None, // No consensus key
488        };
489
490        let key = get_or_create_signing_key(&alix.context, &conversation).unwrap();
491        assert!(key.is_some());
492        // Should return the key from mutable metadata
493        assert_eq!(key.unwrap().as_slice(), mutable_metadata_key.as_slice());
494    }
495
496    #[xmtp_common::test(unwrap_try = true)]
497    async fn test_get_or_create_signing_key_ignores_non_matching_consensus() {
498        tester!(alix);
499        let provider = alix.context.mls_provider();
500        let crypto = provider.crypto();
501        let key_store = provider.key_store();
502
503        let group = alix.create_group(None, None).unwrap();
504
505        // Store a key that doesn't match the consensus
506        let stored_key = crypto.generate_commit_log_key().unwrap();
507        key_store
508            .write_commit_log_key(group.group_id, &stored_key)
509            .unwrap();
510
511        // Set a different consensus key
512        let consensus_key = crypto.generate_commit_log_key().unwrap();
513        let consensus_public_key = xmtp_cryptography::signature::to_public_key(&consensus_key)
514            .unwrap()
515            .to_vec();
516
517        let conversation = StoredGroupCommitLogPublicKey {
518            id: group.group_id,
519            commit_log_public_key: Some(consensus_public_key),
520        };
521
522        let key = get_or_create_signing_key(&alix.context, &conversation).unwrap();
523        // Should return None because stored key doesn't match consensus
524        assert!(key.is_none());
525    }
526
527    #[xmtp_common::test(unwrap_try = true)]
528    async fn test_get_or_create_signing_key_uses_matching_stored_key() {
529        tester!(alix);
530        let provider = alix.context.mls_provider();
531        let crypto = provider.crypto();
532        let key_store = provider.key_store();
533
534        let group = alix.create_group(None, None).unwrap();
535
536        // Store a key
537        let stored_key = crypto.generate_commit_log_key().unwrap();
538        let stored_public_key = xmtp_cryptography::signature::to_public_key(&stored_key)
539            .unwrap()
540            .to_vec();
541        key_store
542            .write_commit_log_key(group.group_id, &stored_key)
543            .unwrap();
544
545        // Set consensus key that matches the stored key
546        let conversation = StoredGroupCommitLogPublicKey {
547            id: group.group_id,
548            commit_log_public_key: Some(stored_public_key),
549        };
550
551        let key = get_or_create_signing_key(&alix.context, &conversation).unwrap();
552        assert!(key.is_some());
553        // Should return the stored key that matches consensus
554        assert_eq!(key.unwrap().as_slice(), stored_key.as_slice());
555    }
556
557    #[xmtp_common::test(unwrap_try = true)]
558    async fn test_get_or_create_signing_key_uses_matching_mutable_metadata() {
559        tester!(alix);
560
561        let group = alix.create_group(None, None).unwrap();
562        let metadata = group.mutable_metadata().unwrap();
563        let metadata_key = metadata.commit_log_signer().unwrap();
564        let metadata_public_key = xmtp_cryptography::signature::to_public_key(&metadata_key)
565            .unwrap()
566            .to_vec();
567
568        // Set consensus key that matches the mutable metadata key
569        let conversation = StoredGroupCommitLogPublicKey {
570            id: group.group_id,
571            commit_log_public_key: Some(metadata_public_key),
572        };
573
574        let key = get_or_create_signing_key(&alix.context, &conversation).unwrap();
575        assert!(key.is_some());
576        // Should return the key from mutable metadata that matches consensus
577        assert_eq!(key.unwrap().as_slice(), metadata_key.as_slice());
578    }
579
580    #[xmtp_common::test(unwrap_try = true)]
581    async fn test_get_or_create_signing_key_returns_none_with_consensus_no_matching_key() {
582        tester!(alix);
583        let provider = alix.context.mls_provider();
584        let crypto = provider.crypto();
585        let key_store = provider.key_store();
586
587        let group = alix.create_group(None, None).unwrap();
588        let group_id = group.group_id;
589
590        // Clear the key store to ensure no stored key exists
591        key_store
592            .delete::<1>(
593                xmtp_db::sql_key_store::COMMIT_LOG_SIGNER_PRIVATE_KEY,
594                &bincode::serialize(&group_id).unwrap(),
595            )
596            .ok();
597
598        // Set a consensus key that we don't have the private key for
599        let consensus_key = crypto.generate_commit_log_key().unwrap();
600        let consensus_public_key = xmtp_cryptography::signature::to_public_key(&consensus_key)
601            .unwrap()
602            .to_vec();
603
604        let conversation = StoredGroupCommitLogPublicKey {
605            id: group_id,
606            commit_log_public_key: Some(consensus_public_key),
607        };
608
609        let key = get_or_create_signing_key(&alix.context, &conversation).unwrap();
610        // Should return None because:
611        // 1. No key exists in the key store
612        // 2. Mutable metadata key doesn't match consensus
613        // 3. We have a consensus key so we can't create a new one
614        assert!(key.is_none());
615    }
616}