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.
This commit is contained in:
2026-03-04 20:59:03 +01:00
parent f57dda3f36
commit 511fc7822e
2 changed files with 188 additions and 0 deletions

View File

@@ -16,4 +16,5 @@ pub mod messaging;
pub mod outbox; pub mod outbox;
pub mod recovery; pub mod recovery;
pub mod state; pub mod state;
pub mod transcript;
pub mod users; pub mod users;

View File

@@ -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<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.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 });
}
}