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.live_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.live_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    // verifies: FORK-031
231    #[xmtp_common::test(unwrap_try = true)]
232    async fn test_verify_commit_log_signature() {
233        tester!(alix);
234        let provider = alix.context.mls_provider();
235        let crypto = provider.crypto();
236
237        let private_key = crypto.generate_commit_log_key().unwrap();
238        let public_key = xmtp_cryptography::signature::to_public_key(&private_key)
239            .unwrap()
240            .to_vec();
241
242        let message = b"test message";
243        let signature_bytes = crypto
244            .sign(
245                openmls::prelude::SignatureScheme::ED25519,
246                message,
247                private_key.as_slice(),
248            )
249            .unwrap();
250
251        let commit_entry = CommitLogEntryProto {
252            serialized_commit_log_entry: message.to_vec(),
253            signature: Some(
254                xmtp_proto::xmtp::identity::associations::RecoverableEd25519Signature {
255                    public_key: public_key.clone(),
256                    bytes: signature_bytes,
257                },
258            ),
259        };
260
261        // Valid signature should verify
262        assert!(
263            crypto
264                .verify_commit_log_signature(&commit_entry, &public_key)
265                .is_ok()
266        );
267
268        // Wrong public key should fail
269        let wrong_public_key = vec![0u8; 32];
270        assert!(
271            crypto
272                .verify_commit_log_signature(&commit_entry, &wrong_public_key)
273                .is_err()
274        );
275
276        // Entry without signature should fail
277        let unsigned_entry = CommitLogEntryProto {
278            serialized_commit_log_entry: message.to_vec(),
279            signature: None,
280        };
281        assert!(
282            crypto
283                .verify_commit_log_signature(&unsigned_entry, &public_key)
284                .is_err()
285        );
286    }
287
288    // verifies: FORK-012
289    #[xmtp_common::test(unwrap_try = true)]
290    async fn test_derive_consensus_public_key_with_valid_signature() {
291        tester!(alix);
292        let provider = alix.context.mls_provider();
293        let crypto = provider.crypto();
294
295        // Use an actual group ID to avoid database conflicts
296        let group = alix.create_group(None, None).unwrap();
297
298        // Create first key pair (this should be chosen as consensus key)
299        let first_private_key = crypto.generate_commit_log_key().unwrap();
300        let first_public_key = xmtp_cryptography::signature::to_public_key(&first_private_key)
301            .unwrap()
302            .to_vec();
303
304        // Create second key pair (this should be ignored)
305        let second_private_key = crypto.generate_commit_log_key().unwrap();
306        let second_public_key = xmtp_cryptography::signature::to_public_key(&second_private_key)
307            .unwrap()
308            .to_vec();
309
310        let first_message = b"first commit";
311        let first_signature = crypto
312            .sign(
313                openmls::prelude::SignatureScheme::ED25519,
314                first_message,
315                first_private_key.as_slice(),
316            )
317            .unwrap();
318
319        let second_message = b"second commit";
320        let second_signature = crypto
321            .sign(
322                openmls::prelude::SignatureScheme::ED25519,
323                second_message,
324                second_private_key.as_slice(),
325            )
326            .unwrap();
327
328        let first_entry = CommitLogEntryProto {
329            serialized_commit_log_entry: first_message.to_vec(),
330            signature: Some(
331                xmtp_proto::xmtp::identity::associations::RecoverableEd25519Signature {
332                    public_key: first_public_key.clone(),
333                    bytes: first_signature,
334                },
335            ),
336        };
337
338        let second_entry = CommitLogEntryProto {
339            serialized_commit_log_entry: second_message.to_vec(),
340            signature: Some(
341                xmtp_proto::xmtp::identity::associations::RecoverableEd25519Signature {
342                    public_key: second_public_key.clone(),
343                    bytes: second_signature,
344                },
345            ),
346        };
347
348        let entries = vec![first_entry, second_entry];
349
350        let result =
351            derive_consensus_public_key(&alix.context, group.group_id.as_slice(), &entries)
352                .await
353                .unwrap();
354        assert!(result.is_some());
355        let consensus_key = result.unwrap();
356        // Should return the FIRST valid public key, not the second
357        assert_eq!(consensus_key, first_public_key);
358        assert_ne!(consensus_key, second_public_key);
359    }
360
361    #[xmtp_common::test(unwrap_try = true)]
362    async fn test_derive_consensus_public_key_with_no_valid_signature() {
363        tester!(alix);
364        let provider = alix.context.mls_provider();
365        let crypto = provider.crypto();
366
367        // Use an actual group ID to avoid database conflicts
368        let group = alix.create_group(None, None).unwrap();
369
370        // Create a valid second entry
371        let valid_private_key = crypto.generate_commit_log_key().unwrap();
372        let valid_public_key = xmtp_cryptography::signature::to_public_key(&valid_private_key)
373            .unwrap()
374            .to_vec();
375
376        let valid_message = b"valid commit";
377        let valid_signature = crypto
378            .sign(
379                openmls::prelude::SignatureScheme::ED25519,
380                valid_message,
381                valid_private_key.as_slice(),
382            )
383            .unwrap();
384
385        // First entry has no signature (should be skipped)
386        let unsigned_entry = CommitLogEntryProto {
387            serialized_commit_log_entry: b"unsigned commit".to_vec(),
388            signature: None,
389        };
390
391        // Second entry has valid signature (should be used)
392        let valid_entry = CommitLogEntryProto {
393            serialized_commit_log_entry: valid_message.to_vec(),
394            signature: Some(
395                xmtp_proto::xmtp::identity::associations::RecoverableEd25519Signature {
396                    public_key: valid_public_key.clone(),
397                    bytes: valid_signature,
398                },
399            ),
400        };
401
402        let entries = vec![unsigned_entry, valid_entry];
403
404        let result =
405            derive_consensus_public_key(&alix.context, group.group_id.as_slice(), &entries)
406                .await
407                .unwrap();
408        assert!(result.is_some());
409        // Should derive from the second entry (first valid one)
410        assert_eq!(result.unwrap(), valid_public_key);
411    }
412
413    #[xmtp_common::test(unwrap_try = true)]
414    async fn test_derive_consensus_public_key_with_invalid_signature() {
415        tester!(alix);
416        let provider = alix.context.mls_provider();
417        let crypto = provider.crypto();
418
419        // Use an actual group ID to avoid database conflicts
420        let group = alix.create_group(None, None).unwrap();
421
422        // Create keys for invalid first entry
423        let invalid_private_key = crypto.generate_commit_log_key().unwrap();
424        let invalid_public_key = xmtp_cryptography::signature::to_public_key(&invalid_private_key)
425            .unwrap()
426            .to_vec();
427
428        // Create valid second entry
429        let valid_private_key = crypto.generate_commit_log_key().unwrap();
430        let valid_public_key = xmtp_cryptography::signature::to_public_key(&valid_private_key)
431            .unwrap()
432            .to_vec();
433
434        let valid_message = b"valid commit";
435        let valid_signature = crypto
436            .sign(
437                openmls::prelude::SignatureScheme::ED25519,
438                valid_message,
439                valid_private_key.as_slice(),
440            )
441            .unwrap();
442
443        // First entry with invalid signature (should be skipped)
444        let invalid_entry = CommitLogEntryProto {
445            serialized_commit_log_entry: b"invalid commit".to_vec(),
446            signature: Some(
447                xmtp_proto::xmtp::identity::associations::RecoverableEd25519Signature {
448                    public_key: invalid_public_key.clone(),
449                    bytes: vec![0u8; 64], // Invalid signature bytes
450                },
451            ),
452        };
453
454        // Second entry with valid signature (should be used)
455        let valid_entry = CommitLogEntryProto {
456            serialized_commit_log_entry: valid_message.to_vec(),
457            signature: Some(
458                xmtp_proto::xmtp::identity::associations::RecoverableEd25519Signature {
459                    public_key: valid_public_key.clone(),
460                    bytes: valid_signature,
461                },
462            ),
463        };
464
465        let entries = vec![invalid_entry, valid_entry];
466
467        let result =
468            derive_consensus_public_key(&alix.context, group.group_id.as_slice(), &entries)
469                .await
470                .unwrap();
471        assert!(result.is_some());
472        let consensus_key = result.unwrap();
473        // Should derive from the second entry (first valid one), not the invalid first one
474        assert_eq!(consensus_key, valid_public_key);
475        assert_ne!(consensus_key, invalid_public_key);
476    }
477
478    #[xmtp_common::test(unwrap_try = true)]
479    async fn test_get_or_create_signing_key_uses_mutable_metadata() {
480        tester!(alix);
481
482        // Create a group - this will have a commit_log_signer in mutable metadata by default
483        let group = alix.create_group(None, None).unwrap();
484        let metadata = group.mutable_metadata().unwrap();
485        let mutable_metadata_key = metadata.commit_log_signer().unwrap();
486
487        let conversation = StoredGroupCommitLogPublicKey {
488            id: group.group_id,
489            commit_log_public_key: None, // No consensus key
490        };
491
492        let key = get_or_create_signing_key(&alix.context, &conversation).unwrap();
493        assert!(key.is_some());
494        // Should return the key from mutable metadata
495        assert_eq!(key.unwrap().as_slice(), mutable_metadata_key.as_slice());
496    }
497
498    #[xmtp_common::test(unwrap_try = true)]
499    async fn test_get_or_create_signing_key_ignores_non_matching_consensus() {
500        tester!(alix);
501        let provider = alix.context.mls_provider();
502        let crypto = provider.crypto();
503        let key_store = provider.key_store();
504
505        let group = alix.create_group(None, None).unwrap();
506
507        // Store a key that doesn't match the consensus
508        let stored_key = crypto.generate_commit_log_key().unwrap();
509        key_store
510            .write_commit_log_key(group.group_id, &stored_key)
511            .unwrap();
512
513        // Set a different consensus key
514        let consensus_key = crypto.generate_commit_log_key().unwrap();
515        let consensus_public_key = xmtp_cryptography::signature::to_public_key(&consensus_key)
516            .unwrap()
517            .to_vec();
518
519        let conversation = StoredGroupCommitLogPublicKey {
520            id: group.group_id,
521            commit_log_public_key: Some(consensus_public_key),
522        };
523
524        let key = get_or_create_signing_key(&alix.context, &conversation).unwrap();
525        // Should return None because stored key doesn't match consensus
526        assert!(key.is_none());
527    }
528
529    #[xmtp_common::test(unwrap_try = true)]
530    async fn test_get_or_create_signing_key_uses_matching_stored_key() {
531        tester!(alix);
532        let provider = alix.context.mls_provider();
533        let crypto = provider.crypto();
534        let key_store = provider.key_store();
535
536        let group = alix.create_group(None, None).unwrap();
537
538        // Store a key
539        let stored_key = crypto.generate_commit_log_key().unwrap();
540        let stored_public_key = xmtp_cryptography::signature::to_public_key(&stored_key)
541            .unwrap()
542            .to_vec();
543        key_store
544            .write_commit_log_key(group.group_id, &stored_key)
545            .unwrap();
546
547        // Set consensus key that matches the stored key
548        let conversation = StoredGroupCommitLogPublicKey {
549            id: group.group_id,
550            commit_log_public_key: Some(stored_public_key),
551        };
552
553        let key = get_or_create_signing_key(&alix.context, &conversation).unwrap();
554        assert!(key.is_some());
555        // Should return the stored key that matches consensus
556        assert_eq!(key.unwrap().as_slice(), stored_key.as_slice());
557    }
558
559    #[xmtp_common::test(unwrap_try = true)]
560    async fn test_get_or_create_signing_key_uses_matching_mutable_metadata() {
561        tester!(alix);
562
563        let group = alix.create_group(None, None).unwrap();
564        let metadata = group.mutable_metadata().unwrap();
565        let metadata_key = metadata.commit_log_signer().unwrap();
566        let metadata_public_key = xmtp_cryptography::signature::to_public_key(&metadata_key)
567            .unwrap()
568            .to_vec();
569
570        // Set consensus key that matches the mutable metadata key
571        let conversation = StoredGroupCommitLogPublicKey {
572            id: group.group_id,
573            commit_log_public_key: Some(metadata_public_key),
574        };
575
576        let key = get_or_create_signing_key(&alix.context, &conversation).unwrap();
577        assert!(key.is_some());
578        // Should return the key from mutable metadata that matches consensus
579        assert_eq!(key.unwrap().as_slice(), metadata_key.as_slice());
580    }
581
582    #[xmtp_common::test(unwrap_try = true)]
583    async fn test_get_or_create_signing_key_returns_none_with_consensus_no_matching_key() {
584        tester!(alix);
585        let provider = alix.context.mls_provider();
586        let crypto = provider.crypto();
587        let key_store = provider.key_store();
588
589        let group = alix.create_group(None, None).unwrap();
590        let group_id = group.group_id;
591
592        // Clear the key store to ensure no stored key exists
593        key_store
594            .delete::<1>(
595                xmtp_db::sql_key_store::COMMIT_LOG_SIGNER_PRIVATE_KEY,
596                &bincode::serialize(&group_id).unwrap(),
597            )
598            .ok();
599
600        // Set a consensus key that we don't have the private key for
601        let consensus_key = crypto.generate_commit_log_key().unwrap();
602        let consensus_public_key = xmtp_cryptography::signature::to_public_key(&consensus_key)
603            .unwrap()
604            .to_vec();
605
606        let conversation = StoredGroupCommitLogPublicKey {
607            id: group_id,
608            commit_log_public_key: Some(consensus_public_key),
609        };
610
611        let key = get_or_create_signing_key(&alix.context, &conversation).unwrap();
612        // Should return None because:
613        // 1. No key exists in the key store
614        // 2. Mutable metadata key doesn't match consensus
615        // 3. We have a consensus key so we can't create a new one
616        assert!(key.is_none());
617    }
618}