//! Transcript archive export and verification. //! //! Wraps `quicprochat_core::transcript` to provide SDK-level functions for //! exporting conversation messages to an encrypted, tamper-evident archive //! and verifying archive integrity. use std::fs; use std::path::Path; use quicprochat_core::transcript::{ read_transcript, validate_transcript_structure, ChainVerdict, TranscriptRecord, TranscriptWriter, }; use crate::conversation::{ConversationId, ConversationStore}; /// Export all messages in a conversation to an encrypted, tamper-evident archive. /// /// Uses Argon2id key derivation from `password` and ChaCha20-Poly1305 for AEAD /// encryption. Each record is chained via SHA-256 of the previous ciphertext. /// /// Returns the number of records written. pub fn export_transcript( store: &ConversationStore, conversation_id: &ConversationId, path: &Path, password: &str, ) -> anyhow::Result { // Use a large limit to get all messages (sorted oldest-first by load_recent_messages). let messages = store.load_recent_messages(conversation_id, usize::MAX)?; let mut buf = Vec::new(); let mut writer = TranscriptWriter::new(password, &mut buf) .map_err(|e| anyhow::anyhow!("create transcript writer: {e}"))?; let mut count = 0u64; for msg in &messages { let sender_key = if msg.sender_key.len() == 32 { &msg.sender_key[..] } else { &[0u8; 32][..] }; writer .write_record( &TranscriptRecord { seq: count, sender_identity: sender_key, timestamp_ms: msg.timestamp_ms, plaintext: &msg.body, }, &mut buf, ) .map_err(|e| anyhow::anyhow!("write record {count}: {e}"))?; count += 1; } fs::write(path, &buf)?; Ok(count) } /// Verify an exported transcript archive. /// /// If `password` is provided, performs full decryption and hash-chain /// verification. Otherwise, only checks structural integrity (file format, /// record boundaries). /// /// Returns the chain verdict. pub fn verify_transcript( path: &Path, password: Option<&str>, ) -> anyhow::Result { let data = fs::read(path)?; if let Some(pw) = password { let (_records, verdict) = read_transcript(pw, &data) .map_err(|e| anyhow::anyhow!("verify transcript: {e}"))?; Ok(verdict) } else { let verdict = validate_transcript_structure(&data) .map_err(|e| anyhow::anyhow!("validate structure: {e}"))?; Ok(verdict) } } #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { use super::*; use crate::conversation::{Conversation, ConversationKind, StoredMessage}; use tempfile::TempDir; fn setup() -> (TempDir, ConversationStore) { let dir = TempDir::new().unwrap(); let db_path = dir.path().join("test.db"); let store = ConversationStore::open(&db_path, None).unwrap(); (dir, store) } fn save_conv(store: &ConversationStore, conv_id: &ConversationId, name: &str) { let conv = Conversation { id: conv_id.clone(), kind: ConversationKind::Group { name: name.to_string(), }, display_name: name.to_string(), mls_group_blob: None, keystore_blob: None, member_keys: vec![], unread_count: 0, last_activity_ms: 0, created_at_ms: 0, is_hybrid: false, last_seen_seq: 0, }; store.save_conversation(&conv).unwrap(); } fn save_msg(store: &ConversationStore, conv_id: &ConversationId, body: &str, ts: u64) { let msg = StoredMessage { conversation_id: conv_id.clone(), message_id: None, sender_key: vec![0xAAu8; 32], sender_name: None, body: body.to_string(), msg_type: "chat".to_string(), ref_msg_id: None, timestamp_ms: ts, is_outgoing: false, }; store.save_message(&msg).unwrap(); } #[test] fn export_and_verify_round_trip() { let (dir, store) = setup(); let conv_id = ConversationId::from_group_name("test-group"); save_conv(&store, &conv_id, "test-group"); save_msg(&store, &conv_id, "Hello!", 1000); save_msg(&store, &conv_id, "World!", 2000); let archive_path = dir.path().join("transcript.qpct"); let count = export_transcript(&store, &conv_id, &archive_path, "test-pw").unwrap(); assert_eq!(count, 2); // Full verification with password. let verdict = verify_transcript(&archive_path, Some("test-pw")).unwrap(); assert_eq!(verdict, ChainVerdict::Ok { records: 2 }); // Structural verification without password. let verdict = verify_transcript(&archive_path, None).unwrap(); assert_eq!(verdict, ChainVerdict::Ok { records: 2 }); } #[test] fn wrong_password_fails_verification() { let (dir, store) = setup(); let conv_id = ConversationId::from_group_name("pw-test"); save_conv(&store, &conv_id, "pw-test"); save_msg(&store, &conv_id, "secret", 1000); let archive_path = dir.path().join("transcript_pw.qpct"); export_transcript(&store, &conv_id, &archive_path, "correct").unwrap(); let result = verify_transcript(&archive_path, Some("wrong")); assert!(result.is_err()); } #[test] fn empty_conversation_exports_empty_archive() { let (dir, store) = setup(); let conv_id = ConversationId::from_group_name("empty"); save_conv(&store, &conv_id, "empty"); let archive_path = dir.path().join("empty.qpct"); let count = export_transcript(&store, &conv_id, &archive_path, "pw").unwrap(); assert_eq!(count, 0); let verdict = verify_transcript(&archive_path, Some("pw")).unwrap(); assert_eq!(verdict, ChainVerdict::Ok { records: 0 }); } }