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}