agent-scroll 0.1.0

Canonical byte-deterministic transcript format for AI-agent conversations (Rust port of @p-vbordei/agent-scroll)
Documentation
use base64::{engine::general_purpose::STANDARD, Engine};
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde_json::{Map, Value};

use crate::canonical::{canonical, hash_canonical};
use crate::schema::{validate_sealed_turn, VerifyFailure, VerifyResult};

pub fn verify(chain: &[Value], pubkey: Option<&[u8; 32]>) -> VerifyResult {
    let mut failures = Vec::new();
    let mut prev_hash: Option<String> = None;

    for (i, item) in chain.iter().enumerate() {
        if let Err(err) = validate_sealed_turn(item) {
            failures.push(VerifyFailure {
                turn: i,
                reason: "SchemaViolation",
                detail: Some(err),
            });
            prev_hash = None;
            continue;
        }
        let obj = item.as_object().unwrap();
        let h = obj
            .get("hash")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        let sig = obj.get("sig").cloned();
        let mut turn_only: Map<String, Value> = obj.clone();
        turn_only.remove("hash");
        turn_only.remove("sig");
        let turn_only_v = Value::Object(turn_only.clone());

        match hash_canonical(&turn_only_v) {
            Ok(computed) if computed == h => {}
            _ => {
                failures.push(VerifyFailure {
                    turn: i,
                    reason: "BadHash",
                    detail: None,
                });
                prev_hash = Some(h);
                continue;
            }
        }

        if i > 0 {
            let got = turn_only
                .get("prev_hash")
                .and_then(|v| v.as_str())
                .map(String::from);
            if got != prev_hash {
                failures.push(VerifyFailure {
                    turn: i,
                    reason: "BrokenChain",
                    detail: None,
                });
            }
        }

        if let (Some(sig), Some(pk_bytes)) = (sig, pubkey) {
            let sig_b64 = sig.get("sig").and_then(|v| v.as_str()).unwrap_or("");
            let canonical_bytes = match canonical(&turn_only_v) {
                Ok(b) => b,
                Err(_) => {
                    failures.push(VerifyFailure {
                        turn: i,
                        reason: "BadSignature",
                        detail: None,
                    });
                    prev_hash = Some(h);
                    continue;
                }
            };
            let ok = (|| -> Option<()> {
                let bytes = STANDARD.decode(sig_b64.as_bytes()).ok()?;
                let arr: [u8; 64] = bytes.try_into().ok()?;
                let s = Signature::from_bytes(&arr);
                let vk = VerifyingKey::from_bytes(pk_bytes).ok()?;
                vk.verify(&canonical_bytes, &s).ok()?;
                Some(())
            })()
            .is_some();
            if !ok {
                failures.push(VerifyFailure {
                    turn: i,
                    reason: "BadSignature",
                    detail: None,
                });
            }
        }

        prev_hash = Some(h);
    }

    VerifyResult {
        ok: failures.is_empty(),
        failures,
    }
}