Rename all crate directories, package names, binary names, proto package/module paths, ALPN strings, env var prefixes, config filenames, mDNS service names, and plugin ABI symbols from quicproquo/qpq to quicprochat/qpc.
188 lines
6.0 KiB
Rust
188 lines
6.0 KiB
Rust
//! 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<u64> {
|
|
// 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<ChainVerdict> {
|
|
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 });
|
|
}
|
|
}
|