From 511fc7822eedf30063aa9be6f36230a07125b96a Mon Sep 17 00:00:00 2001 From: Christian Nennemann Date: Wed, 4 Mar 2026 20:59:03 +0100 Subject: [PATCH] feat(sdk): add transcript archive export and verification SDK-level export_transcript() writes all conversation messages to an encrypted, tamper-evident archive using the existing core transcript format (Argon2id + ChaCha20-Poly1305, CBOR records, SHA-256 chain). verify_transcript() supports both full decryption + chain check and structural-only validation without the password. --- crates/quicproquo-sdk/src/lib.rs | 1 + crates/quicproquo-sdk/src/transcript.rs | 187 ++++++++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 crates/quicproquo-sdk/src/transcript.rs diff --git a/crates/quicproquo-sdk/src/lib.rs b/crates/quicproquo-sdk/src/lib.rs index 16192cf..7b551ab 100644 --- a/crates/quicproquo-sdk/src/lib.rs +++ b/crates/quicproquo-sdk/src/lib.rs @@ -16,4 +16,5 @@ pub mod messaging; pub mod outbox; pub mod recovery; pub mod state; +pub mod transcript; pub mod users; diff --git a/crates/quicproquo-sdk/src/transcript.rs b/crates/quicproquo-sdk/src/transcript.rs new file mode 100644 index 0000000..a702f9b --- /dev/null +++ b/crates/quicproquo-sdk/src/transcript.rs @@ -0,0 +1,187 @@ +//! Transcript archive export and verification. +//! +//! Wraps `quicproquo_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 quicproquo_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.qpqt"); + 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.qpqt"); + 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.qpqt"); + 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 }); + } +}