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.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 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.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 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)]
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 assert!(
263 crypto
264 .verify_commit_log_signature(&commit_entry, &public_key)
265 .is_ok()
266 );
267
268 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 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 #[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 let group = alix.create_group(None, None).unwrap();
297
298 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 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 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 let group = alix.create_group(None, None).unwrap();
369
370 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 let unsigned_entry = CommitLogEntryProto {
387 serialized_commit_log_entry: b"unsigned commit".to_vec(),
388 signature: None,
389 };
390
391 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 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 let group = alix.create_group(None, None).unwrap();
421
422 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 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 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], },
451 ),
452 };
453
454 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 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 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, };
491
492 let key = get_or_create_signing_key(&alix.context, &conversation).unwrap();
493 assert!(key.is_some());
494 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 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 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 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 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 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 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 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 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 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 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 assert!(key.is_none());
617 }
618}