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 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 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 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 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 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 assert!(
262 crypto
263 .verify_commit_log_signature(&commit_entry, &public_key)
264 .is_ok()
265 );
266
267 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 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 let group = alix.create_group(None, None).unwrap();
295
296 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 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 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 let group = alix.create_group(None, None).unwrap();
367
368 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 let unsigned_entry = CommitLogEntryProto {
385 serialized_commit_log_entry: b"unsigned commit".to_vec(),
386 signature: None,
387 };
388
389 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 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 let group = alix.create_group(None, None).unwrap();
419
420 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 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 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], },
449 ),
450 };
451
452 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 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 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, };
489
490 let key = get_or_create_signing_key(&alix.context, &conversation).unwrap();
491 assert!(key.is_some());
492 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 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 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 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 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 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 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 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 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 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 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 assert!(key.is_none());
615 }
616}