agent-scroll 0.1.0

Canonical byte-deterministic transcript format for AI-agent conversations (Rust port of @p-vbordei/agent-scroll)
Documentation
use std::fs;
use std::path::PathBuf;

use agent_scroll::{canonical, deserialize, seal_chain, serialize, verify, SignOpts};
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
use serde_json::{json, Value};

fn fixtures_dir() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures")
}

fn gen_keypair() -> ([u8; 32], [u8; 32]) {
    let sk = SigningKey::generate(&mut OsRng);
    let pk = sk.verifying_key().to_bytes();
    (sk.to_bytes(), pk)
}

#[test]
fn c1_byte_equality() {
    let turns: Vec<Value> =
        serde_json::from_slice(&fs::read(fixtures_dir().join("c1-turns.json")).unwrap()).unwrap();
    let hex_map: serde_json::Map<String, Value> =
        serde_json::from_slice(&fs::read(fixtures_dir().join("c1-hex.json")).unwrap()).unwrap();
    assert!(
        hex_map.len() >= 20,
        "expected >=20 vectors, got {}",
        hex_map.len()
    );

    for (k, t) in turns.iter().enumerate() {
        let bytes = canonical(t).unwrap();
        let actual = hex_lower(&bytes);
        let expected = hex_map
            .get(&k.to_string())
            .expect("missing hex")
            .as_str()
            .unwrap();
        assert_eq!(actual, expected, "C1: turn {k} mismatch");
    }
}

fn hex_lower(bytes: &[u8]) -> String {
    let mut s = String::with_capacity(bytes.len() * 2);
    for b in bytes {
        s.push_str(&format!("{:02x}", b));
    }
    s
}

#[test]
fn c2_mutation_detection() {
    let (priv_key, pub_key) = gen_keypair();
    let base = json!({
        "version": "scroll/0.1",
        "turn": 0,
        "role": "user",
        "model": { "vendor": "anthropic", "id": "claude-sonnet-4-5" },
        "params": { "temperature": 0, "top_p": 1 },
        "messages": [{ "role": "user", "content": "mutation test payload" }],
        "timestamp_ns": 1700000000000000000u64,
    });
    let chain = seal_chain(
        &[base],
        Some(&SignOpts {
            privkey: priv_key,
            pubkey: pub_key,
        }),
    )
    .unwrap();
    let sealed = &chain[0];
    let hash = sealed["hash"].as_str().unwrap();
    let hash_hex = &hash[("sha256:".len())..];

    // Part A: flip each hash char.
    let mut caught = 0;
    for i in 0..hash_hex.len() {
        let chars: Vec<char> = hash_hex.chars().collect();
        let mut new_chars = chars.clone();
        new_chars[i] = if chars[i] == '0' { 'f' } else { '0' };
        let new_hash: String = new_chars.iter().collect();
        let mut tampered = sealed.clone();
        tampered["hash"] = json!(format!("sha256:{new_hash}"));
        let r = verify(&[tampered], Some(&pub_key));
        if !r.ok {
            caught += 1;
        }
    }
    assert_eq!(
        caught,
        hash_hex.len(),
        "C2(A): caught {caught}/{}",
        hash_hex.len()
    );

    // Part B: body byte mutation.
    let mut turn_only = sealed.as_object().unwrap().clone();
    turn_only.remove("hash");
    turn_only.remove("sig");
    let body = canonical(&Value::Object(turn_only)).unwrap();
    let limit = body.len().min(256);
    let mut fails = 0;
    for i in 0..limit {
        let mut mutated = body.clone();
        mutated[i] ^= 0x80;
        let text = match std::str::from_utf8(&mutated) {
            Ok(s) => s,
            Err(_) => {
                fails += 1;
                continue;
            }
        };
        let parsed: Value = match serde_json::from_str(text) {
            Ok(v) => v,
            Err(_) => {
                fails += 1;
                continue;
            }
        };
        if let Value::Object(mut obj) = parsed {
            obj.insert("hash".into(), json!(hash));
            if let Some(sig) = sealed.get("sig") {
                obj.insert("sig".into(), sig.clone());
            }
            let chain = vec![Value::Object(obj)];
            let r = verify(&chain, Some(&pub_key));
            if !r.ok {
                fails += 1;
            }
        } else {
            fails += 1;
        }
    }
    assert!(
        fails >= (limit * 4 / 5),
        "C2(B): only caught {fails}/{limit}"
    );
}

#[test]
fn c3_roundtrip() {
    let turns: Vec<Value> =
        serde_json::from_slice(&fs::read(fixtures_dir().join("c1-turns.json")).unwrap()).unwrap();

    for t in &turns {
        let bytes = serialize(t).unwrap();
        let recovered = deserialize(&bytes).unwrap();
        assert_eq!(canonical(t).unwrap(), canonical(&recovered).unwrap());
    }

    let (priv_key, pub_key) = gen_keypair();
    let unsigned = seal_chain(&turns[..5], None).unwrap();
    for s in &unsigned {
        let bytes = serialize(s).unwrap();
        let recovered = deserialize(&bytes).unwrap();
        assert_eq!(canonical(s).unwrap(), canonical(&recovered).unwrap());
    }
    let signed = seal_chain(
        &turns[..5],
        Some(&SignOpts {
            privkey: priv_key,
            pubkey: pub_key,
        }),
    )
    .unwrap();
    for s in &signed {
        let bytes = serialize(s).unwrap();
        let recovered = deserialize(&bytes).unwrap();
        assert_eq!(canonical(s).unwrap(), canonical(&recovered).unwrap());
    }
}

#[test]
fn c4_chain_tamper() {
    let (priv_key, pub_key) = gen_keypair();
    let mk = |i: usize, content: &str| {
        json!({
            "version": "scroll/0.1",
            "turn": i,
            "role": if i.is_multiple_of(2) { "user" } else { "assistant" },
            "model": { "vendor": "anthropic", "id": "claude-sonnet-4-5" },
            "params": { "temperature": 0, "top_p": 1 },
            "messages": [{"role": if i.is_multiple_of(2) {"user"} else {"assistant"}, "content": content}],
            "timestamp_ns": 1700000000000000000u64 + i as u64,
        })
    };

    let raw: Vec<Value> = (0..5).map(|i| mk(i, &format!("msg{i}"))).collect();
    let chain = seal_chain(
        &raw,
        Some(&SignOpts {
            privkey: priv_key,
            pubkey: pub_key,
        }),
    )
    .unwrap();
    assert_eq!(chain.len(), 5);
    assert!(verify(&chain, Some(&pub_key)).ok);

    let swapped = vec![
        chain[0].clone(),
        chain[2].clone(),
        chain[1].clone(),
        chain[3].clone(),
        chain[4].clone(),
    ];
    assert!(!verify(&swapped, Some(&pub_key)).ok);

    let mut t3_mut = chain[3].clone();
    t3_mut["prev_hash"] = json!(format!("sha256:{}", "a".repeat(64)));
    let broken_prev = vec![
        chain[0].clone(),
        chain[1].clone(),
        chain[2].clone(),
        t3_mut,
        chain[4].clone(),
    ];
    assert!(!verify(&broken_prev, Some(&pub_key)).ok);

    let mut t2_mut = chain[2].clone();
    t2_mut["hash"] = json!(format!("sha256:{}", "b".repeat(64)));
    let broken_hash = vec![
        chain[0].clone(),
        chain[1].clone(),
        t2_mut,
        chain[3].clone(),
        chain[4].clone(),
    ];
    assert!(!verify(&broken_hash, Some(&pub_key)).ok);
}