immutable-trace 0.1.7

Tamper-evident immutable audit trace: signing, verification, ingestion and CLI
Documentation
mod agent;
mod chain;
mod crypto;
pub mod ingest;
mod record;

pub use agent::build_signed_record;
pub use chain::{verify_chain, ChainError};
pub use crypto::{compute_payload_hash, sign_payload_hash, verify_payload_signature};
pub use ingest::{
    AuditLedger, InMemoryAuditLedger, InMemoryOperationLog, InMemoryRawDataStore, IngestDecision,
    IngestService, IngestServiceError, OperationLogEntry, OperationLogStore, RawDataStore,
    IngestError, IngestState,
};
#[cfg(feature = "s3")]
pub use ingest::{S3Backend, S3CompatibleRawDataStore, S3ObjectStoreConfig, S3StoreError};
pub use record::{AuditRecord, Hash32, Signature64};

use std::{fs, path::Path};

use ed25519_dalek::{SigningKey, VerifyingKey};
use thiserror::Error;

#[derive(Debug, Error)]
pub enum CliError {
    #[error("invalid hex input: {0}")]
    InvalidHex(String),
    #[error("invalid byte length: expected {expected}, actual {actual}")]
    InvalidLength { expected: usize, actual: usize },
    #[error("io error: {0}")]
    Io(#[from] std::io::Error),
    #[error("json error: {0}")]
    Json(#[from] serde_json::Error),
    #[error("chain verification failed: {0}")]
    Chain(String),
}

pub fn parse_fixed_hex<const N: usize>(value: &str) -> Result<[u8; N], CliError> {
    let raw = hex::decode(value).map_err(|e| CliError::InvalidHex(e.to_string()))?;
    if raw.len() != N {
        return Err(CliError::InvalidLength {
            expected: N,
            actual: raw.len(),
        });
    }
    let mut out = [0u8; N];
    out.copy_from_slice(&raw);
    Ok(out)
}

pub fn sign_record(
    device_id: String,
    sequence: u64,
    timestamp_ms: u64,
    payload: Vec<u8>,
    prev_hash: Hash32,
    object_ref: String,
    private_key_hex: &str,
) -> Result<AuditRecord, CliError> {
    let key_bytes = parse_fixed_hex::<32>(private_key_hex)?;
    let signing_key = SigningKey::from_bytes(&key_bytes);

    Ok(build_signed_record(
        device_id,
        sequence,
        timestamp_ms,
        &payload,
        prev_hash,
        object_ref,
        &signing_key,
    ))
}

pub fn verify_record(record: &AuditRecord, public_key_hex: &str) -> Result<bool, CliError> {
    let public_key_bytes = parse_fixed_hex::<32>(public_key_hex)?;
    let key = VerifyingKey::from_bytes(&public_key_bytes)
        .map_err(|e| CliError::InvalidHex(e.to_string()))?;
    Ok(verify_payload_signature(
        &key,
        &record.payload_hash,
        &record.signature,
    ))
}

pub fn verify_chain_file(path: &Path) -> Result<(), CliError> {
    let content = fs::read_to_string(path)?;
    let records: Vec<AuditRecord> = serde_json::from_str(&content)?;
    verify_chain(&records).map_err(|e| CliError::Chain(e.to_string()))
}

pub fn verify_chain_records(records: &[AuditRecord]) -> Result<(), CliError> {
    verify_chain(records).map_err(|e| CliError::Chain(e.to_string()))
}

pub fn build_lift_inspection_demo_records(
    device_id: &str,
    private_key_hex: &str,
    start_timestamp_ms: u64,
    object_prefix: &str,
) -> Result<Vec<AuditRecord>, CliError> {
    let steps = [
        "check=door,status=ok,open_close_cycle=3",
        "check=vibration,status=ok,rms=0.18",
        "check=emergency_brake,status=ok,response_ms=120",
    ];

    let mut records = Vec::with_capacity(steps.len());
    let mut prev_hash = AuditRecord::zero_hash();

    for (index, step) in steps.iter().enumerate() {
        let sequence = (index as u64) + 1;
        let timestamp_ms = start_timestamp_ms + (index as u64) * 60_000;
        let payload = format!(
            "scenario=lift-inspection,device={device_id},sequence={sequence},{step}"
        );
        let object_ref = format!("{object_prefix}/inspection-{sequence}.bin");

        let record = sign_record(
            device_id.to_string(),
            sequence,
            timestamp_ms,
            payload.into_bytes(),
            prev_hash,
            object_ref,
            private_key_hex,
        )?;

        prev_hash = record.hash();
        records.push(record);
    }

    Ok(records)
}

pub fn write_record_json(path: Option<&Path>, record: &AuditRecord) -> Result<(), CliError> {
    let json = serde_json::to_string_pretty(record)?;
    match path {
        Some(file) => {
            fs::write(file, json)?;
            Ok(())
        }
        None => {
            println!("{json}");
            Ok(())
        }
    }
}

pub fn write_records_json(path: &Path, records: &[AuditRecord]) -> Result<(), CliError> {
    let json = serde_json::to_string_pretty(records)?;
    fs::write(path, json)?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_fixed_hex_requires_exact_length() {
        let err = parse_fixed_hex::<32>("abcd").unwrap_err();
        match err {
            CliError::InvalidLength { expected, actual } => {
                assert_eq!(expected, 32);
                assert_eq!(actual, 2);
            }
            _ => panic!("unexpected error variant"),
        }
    }

    #[test]
    fn sign_and_verify_record_roundtrip() {
        let private_key_hex = "0101010101010101010101010101010101010101010101010101010101010101";
        let private_key = parse_fixed_hex::<32>(private_key_hex).expect("valid private key hex");
        let signing_key = SigningKey::from_bytes(&private_key);
        let public_key_hex = hex::encode(signing_key.verifying_key().to_bytes());

        let record = sign_record(
            "lift-01".to_string(),
            1,
            1_700_000_000_000,
            b"temperature=40".to_vec(),
            AuditRecord::zero_hash(),
            "s3://bucket/lift-01/1.bin".to_string(),
            private_key_hex,
        )
        .expect("record should be signed");

        let valid = verify_record(&record, &public_key_hex).expect("verify should run");
        assert!(valid);
    }

    #[test]
    fn build_lift_demo_records_are_chain_valid() {
        let private_key_hex = "0101010101010101010101010101010101010101010101010101010101010101";
        let records = build_lift_inspection_demo_records(
            "lift-01",
            private_key_hex,
            1_700_000_000_000,
            "s3://bucket/lift-01",
        )
        .expect("demo records should be generated");

        assert_eq!(records.len(), 3);
        verify_chain_records(&records).expect("demo chain should be valid");
    }

    #[test]
    fn tampered_lift_demo_chain_is_detected() {
        let private_key_hex = "0101010101010101010101010101010101010101010101010101010101010101";
        let mut records = build_lift_inspection_demo_records(
            "lift-01",
            private_key_hex,
            1_700_000_000_000,
            "s3://bucket/lift-01",
        )
        .expect("demo records should be generated");

        records[0].payload_hash[0] ^= 0xFF;

        let err = verify_chain_records(&records).expect_err("tampered chain must fail");
        match err {
            CliError::Chain(message) => {
                assert!(message.contains("invalid previous hash"));
            }
            _ => panic!("unexpected error variant"),
        }
    }
}