Skip to main content

immutable_trace/
lib.rs

1mod agent;
2mod chain;
3mod crypto;
4pub mod ingest;
5mod record;
6
7pub use agent::build_signed_record;
8pub use chain::{verify_chain, ChainError};
9pub use crypto::{compute_payload_hash, sign_payload_hash, verify_payload_signature};
10pub use ingest::{
11    AuditLedger, InMemoryAuditLedger, InMemoryOperationLog, InMemoryRawDataStore, IngestDecision,
12    IngestService, IngestServiceError, OperationLogEntry, OperationLogStore, RawDataStore,
13    IngestError, IngestState,
14};
15#[cfg(feature = "s3")]
16pub use ingest::{S3Backend, S3CompatibleRawDataStore, S3ObjectStoreConfig, S3StoreError};
17pub use record::{AuditRecord, Hash32, Signature64};
18
19use std::{fs, path::Path};
20
21use ed25519_dalek::{SigningKey, VerifyingKey};
22use thiserror::Error;
23
24#[derive(Debug, Error)]
25pub enum CliError {
26    #[error("invalid hex input: {0}")]
27    InvalidHex(String),
28    #[error("invalid byte length: expected {expected}, actual {actual}")]
29    InvalidLength { expected: usize, actual: usize },
30    #[error("io error: {0}")]
31    Io(#[from] std::io::Error),
32    #[error("json error: {0}")]
33    Json(#[from] serde_json::Error),
34    #[error("chain verification failed: {0}")]
35    Chain(String),
36}
37
38pub fn parse_fixed_hex<const N: usize>(value: &str) -> Result<[u8; N], CliError> {
39    let raw = hex::decode(value).map_err(|e| CliError::InvalidHex(e.to_string()))?;
40    if raw.len() != N {
41        return Err(CliError::InvalidLength {
42            expected: N,
43            actual: raw.len(),
44        });
45    }
46    let mut out = [0u8; N];
47    out.copy_from_slice(&raw);
48    Ok(out)
49}
50
51pub fn sign_record(
52    device_id: String,
53    sequence: u64,
54    timestamp_ms: u64,
55    payload: Vec<u8>,
56    prev_hash: Hash32,
57    object_ref: String,
58    private_key_hex: &str,
59) -> Result<AuditRecord, CliError> {
60    let key_bytes = parse_fixed_hex::<32>(private_key_hex)?;
61    let signing_key = SigningKey::from_bytes(&key_bytes);
62
63    Ok(build_signed_record(
64        device_id,
65        sequence,
66        timestamp_ms,
67        &payload,
68        prev_hash,
69        object_ref,
70        &signing_key,
71    ))
72}
73
74pub fn verify_record(record: &AuditRecord, public_key_hex: &str) -> Result<bool, CliError> {
75    let public_key_bytes = parse_fixed_hex::<32>(public_key_hex)?;
76    let key = VerifyingKey::from_bytes(&public_key_bytes)
77        .map_err(|e| CliError::InvalidHex(e.to_string()))?;
78    Ok(verify_payload_signature(
79        &key,
80        &record.payload_hash,
81        &record.signature,
82    ))
83}
84
85pub fn verify_chain_file(path: &Path) -> Result<(), CliError> {
86    let content = fs::read_to_string(path)?;
87    let records: Vec<AuditRecord> = serde_json::from_str(&content)?;
88    verify_chain(&records).map_err(|e| CliError::Chain(e.to_string()))
89}
90
91pub fn verify_chain_records(records: &[AuditRecord]) -> Result<(), CliError> {
92    verify_chain(records).map_err(|e| CliError::Chain(e.to_string()))
93}
94
95pub fn build_lift_inspection_demo_records(
96    device_id: &str,
97    private_key_hex: &str,
98    start_timestamp_ms: u64,
99    object_prefix: &str,
100) -> Result<Vec<AuditRecord>, CliError> {
101    let steps = [
102        "check=door,status=ok,open_close_cycle=3",
103        "check=vibration,status=ok,rms=0.18",
104        "check=emergency_brake,status=ok,response_ms=120",
105    ];
106
107    let mut records = Vec::with_capacity(steps.len());
108    let mut prev_hash = AuditRecord::zero_hash();
109
110    for (index, step) in steps.iter().enumerate() {
111        let sequence = (index as u64) + 1;
112        let timestamp_ms = start_timestamp_ms + (index as u64) * 60_000;
113        let payload = format!(
114            "scenario=lift-inspection,device={device_id},sequence={sequence},{step}"
115        );
116        let object_ref = format!("{object_prefix}/inspection-{sequence}.bin");
117
118        let record = sign_record(
119            device_id.to_string(),
120            sequence,
121            timestamp_ms,
122            payload.into_bytes(),
123            prev_hash,
124            object_ref,
125            private_key_hex,
126        )?;
127
128        prev_hash = record.hash();
129        records.push(record);
130    }
131
132    Ok(records)
133}
134
135pub fn write_record_json(path: Option<&Path>, record: &AuditRecord) -> Result<(), CliError> {
136    let json = serde_json::to_string_pretty(record)?;
137    match path {
138        Some(file) => {
139            fs::write(file, json)?;
140            Ok(())
141        }
142        None => {
143            println!("{json}");
144            Ok(())
145        }
146    }
147}
148
149pub fn write_records_json(path: &Path, records: &[AuditRecord]) -> Result<(), CliError> {
150    let json = serde_json::to_string_pretty(records)?;
151    fs::write(path, json)?;
152    Ok(())
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn parse_fixed_hex_requires_exact_length() {
161        let err = parse_fixed_hex::<32>("abcd").unwrap_err();
162        match err {
163            CliError::InvalidLength { expected, actual } => {
164                assert_eq!(expected, 32);
165                assert_eq!(actual, 2);
166            }
167            _ => panic!("unexpected error variant"),
168        }
169    }
170
171    #[test]
172    fn sign_and_verify_record_roundtrip() {
173        let private_key_hex = "0101010101010101010101010101010101010101010101010101010101010101";
174        let private_key = parse_fixed_hex::<32>(private_key_hex).expect("valid private key hex");
175        let signing_key = SigningKey::from_bytes(&private_key);
176        let public_key_hex = hex::encode(signing_key.verifying_key().to_bytes());
177
178        let record = sign_record(
179            "lift-01".to_string(),
180            1,
181            1_700_000_000_000,
182            b"temperature=40".to_vec(),
183            AuditRecord::zero_hash(),
184            "s3://bucket/lift-01/1.bin".to_string(),
185            private_key_hex,
186        )
187        .expect("record should be signed");
188
189        let valid = verify_record(&record, &public_key_hex).expect("verify should run");
190        assert!(valid);
191    }
192
193    #[test]
194    fn build_lift_demo_records_are_chain_valid() {
195        let private_key_hex = "0101010101010101010101010101010101010101010101010101010101010101";
196        let records = build_lift_inspection_demo_records(
197            "lift-01",
198            private_key_hex,
199            1_700_000_000_000,
200            "s3://bucket/lift-01",
201        )
202        .expect("demo records should be generated");
203
204        assert_eq!(records.len(), 3);
205        verify_chain_records(&records).expect("demo chain should be valid");
206    }
207
208    #[test]
209    fn tampered_lift_demo_chain_is_detected() {
210        let private_key_hex = "0101010101010101010101010101010101010101010101010101010101010101";
211        let mut records = build_lift_inspection_demo_records(
212            "lift-01",
213            private_key_hex,
214            1_700_000_000_000,
215            "s3://bucket/lift-01",
216        )
217        .expect("demo records should be generated");
218
219        records[0].payload_hash[0] ^= 0xFF;
220
221        let err = verify_chain_records(&records).expect_err("tampered chain must fail");
222        match err {
223            CliError::Chain(message) => {
224                assert!(message.contains("invalid previous hash"));
225            }
226            _ => panic!("unexpected error variant"),
227        }
228    }
229}