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:
@@ -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;
|
||||||
|
|||||||
187
crates/quicproquo-sdk/src/transcript.rs
Normal file
187
crates/quicproquo-sdk/src/transcript.rs
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user