use chrono::{DateTime, Utc};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TranscriptDecision {
Allowed,
Denied,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TranscriptEntry {
pub seq: u64,
pub session_id: String,
pub sender: String,
pub recipient: String,
pub label: String,
pub decision: TranscriptDecision,
pub reason: Option<String>,
pub timestamp: DateTime<Utc>,
pub chain_hash: String,
pub signature: String,
}
#[derive(Debug)]
pub struct SessionTranscript {
entries: Vec<TranscriptEntry>,
signing_key: SigningKey,
last_chain_hash: String,
seq: u64,
}
impl SessionTranscript {
pub fn new(signing_key: SigningKey) -> Self {
let genesis = sha256_hex(b"session-transcript-genesis");
Self {
entries: Vec::new(),
signing_key,
last_chain_hash: genesis,
seq: 0,
}
}
pub fn new_ephemeral() -> Self {
let mut secret = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut secret);
let signing_key = SigningKey::from_bytes(&secret);
Self::new(signing_key)
}
pub fn verifying_key(&self) -> VerifyingKey {
self.signing_key.verifying_key()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn entries(&self) -> &[TranscriptEntry] {
&self.entries
}
#[cfg(test)]
pub fn entries_mut(&mut self) -> &mut Vec<TranscriptEntry> {
&mut self.entries
}
pub fn record(
&mut self,
session_id: &str,
sender: &str,
recipient: &str,
label: &str,
decision: TranscriptDecision,
reason: Option<String>,
) -> String {
let seq = self.seq;
self.seq += 1;
let timestamp = Utc::now();
let entry_data = EntryFields {
seq,
session_id,
sender,
recipient,
label,
decision,
reason: reason.as_deref().unwrap_or(""),
timestamp_rfc3339: ×tamp.to_rfc3339(),
}
.canonical();
let chain_input = format!("{}{}", self.last_chain_hash, entry_data);
let chain_hash = sha256_hex(chain_input.as_bytes());
let signature_bytes = self.signing_key.sign(chain_hash.as_bytes());
let signature = hex::encode(signature_bytes.to_bytes());
let entry = TranscriptEntry {
seq,
session_id: session_id.to_string(),
sender: sender.to_string(),
recipient: recipient.to_string(),
label: label.to_string(),
decision,
reason,
timestamp,
chain_hash: chain_hash.clone(),
signature,
};
self.last_chain_hash = chain_hash.clone();
self.entries.push(entry);
chain_hash
}
pub fn verify(&self) -> bool {
let verifying_key = self.signing_key.verifying_key();
let mut expected_prev_hash = sha256_hex(b"session-transcript-genesis");
for entry in &self.entries {
let entry_data = EntryFields {
seq: entry.seq,
session_id: &entry.session_id,
sender: &entry.sender,
recipient: &entry.recipient,
label: &entry.label,
decision: entry.decision,
reason: entry.reason.as_deref().unwrap_or(""),
timestamp_rfc3339: &entry.timestamp.to_rfc3339(),
}
.canonical();
let chain_input = format!("{}{}", expected_prev_hash, entry_data);
let expected_chain_hash = sha256_hex(chain_input.as_bytes());
if entry.chain_hash != expected_chain_hash {
return false;
}
let Ok(sig_bytes) = hex::decode(&entry.signature) else {
return false;
};
let Ok(sig_array) = <[u8; 64]>::try_from(sig_bytes.as_slice()) else {
return false;
};
let signature = Signature::from_bytes(&sig_array);
if verifying_key
.verify(entry.chain_hash.as_bytes(), &signature)
.is_err()
{
return false;
}
expected_prev_hash = entry.chain_hash.clone();
}
true
}
}
struct EntryFields<'a> {
seq: u64,
session_id: &'a str,
sender: &'a str,
recipient: &'a str,
label: &'a str,
decision: TranscriptDecision,
reason: &'a str,
timestamp_rfc3339: &'a str,
}
impl EntryFields<'_> {
fn canonical(&self) -> String {
fn f(s: &str) -> String {
format!("{}:{}", s.len(), s)
}
let decision_str = match self.decision {
TranscriptDecision::Allowed => "allowed",
TranscriptDecision::Denied => "denied",
};
format!(
"{}|{}{}{}{}{}{}{}",
self.seq,
f(self.session_id),
f(self.sender),
f(self.recipient),
f(self.label),
f(decision_str),
f(self.reason),
f(self.timestamp_rfc3339),
)
}
}
fn sha256_hex(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
hex::encode(hasher.finalize())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn records_and_verifies_chain() {
let mut t = SessionTranscript::new_ephemeral();
t.record("s1", "A", "B", "task", TranscriptDecision::Allowed, None);
t.record("s1", "B", "A", "ok", TranscriptDecision::Allowed, None);
t.record(
"s1",
"A",
"C",
"task",
TranscriptDecision::Denied,
Some("illegal".into()),
);
assert_eq!(t.len(), 3);
assert!(t.verify(), "intact chain must verify");
}
#[test]
fn tampering_breaks_verification() {
let mut t = SessionTranscript::new_ephemeral();
t.record("s1", "A", "B", "task", TranscriptDecision::Allowed, None);
t.record("s1", "B", "A", "ok", TranscriptDecision::Allowed, None);
t.entries_mut()[0].label = "tampered".into();
assert!(!t.verify(), "tampered chain must fail verification");
}
#[test]
fn pipe_in_fields_is_not_forgeable() {
let mut a = SessionTranscript::new_ephemeral();
a.record("s", "A|B", "C", "task", TranscriptDecision::Allowed, None);
assert!(a.verify());
let mut b = a;
b.entries_mut()[0].sender = "A".into();
b.entries_mut()[0].recipient = "B|C".into();
assert!(
!b.verify(),
"length-prefixed fields must make the swap detectable"
);
}
}