1mod agent;
2pub mod buffer;
3pub mod identity;
4pub mod integrity;
5pub mod ingest;
6mod record;
7pub mod update;
8#[cfg(any(feature = "transport-http", feature = "transport-mqtt", feature = "transport-mqtt-tls", feature = "transport-tls"))]
9pub mod transport;
10
11pub use agent::build_signed_record;
12pub use buffer::{BufferStore, BufferedEntry, FlushError, FlushReport, InMemoryBufferStore, OfflineBuffer};
13#[cfg(feature = "buffer-sqlite")]
14pub use buffer::sqlite::SqliteBufferStore;
15pub use identity::{sign_payload_hash, verify_payload_signature};
16pub use integrity::{compute_payload_hash, verify_chain, ChainError};
17pub use ingest::{
18 AllowedSource, AuditLedger, InMemoryAuditLedger, InMemoryOperationLog, InMemoryRawDataStore,
19 IngestDecision, IngestError, IngestService, IngestServiceError, IngestState,
20 IntegrityPolicyGate, NetworkPolicy, NetworkPolicyError, OperationLogEntry, OperationLogStore,
21 RawDataStore,
22};
23#[cfg(feature = "s3")]
24pub use ingest::{S3Backend, S3CompatibleRawDataStore, S3ObjectStoreConfig, S3StoreError};
25#[cfg(feature = "postgres")]
26pub use ingest::{PostgresAuditLedger, PostgresOperationLog, PostgresStoreError};
27#[cfg(feature = "async-ingest")]
28pub use ingest::{
29 AsyncAuditLedger, AsyncInMemoryAuditLedger, AsyncInMemoryOperationLog,
30 AsyncInMemoryRawDataStore, AsyncIngestService, AsyncOperationLogStore, AsyncRawDataStore,
31};
32pub use record::{AuditRecord, Hash32, Signature64};
33
34use std::{fs, path::Path};
35
36use ed25519_dalek::{SigningKey, VerifyingKey};
37use rand::rngs::OsRng;
38use serde::{Deserialize, Serialize};
39use thiserror::Error;
40
41#[derive(Debug, Error)]
42pub enum CliError {
43 #[error("invalid hex input: {0}")]
44 InvalidHex(String),
45 #[error("invalid byte length: expected {expected}, actual {actual}")]
46 InvalidLength { expected: usize, actual: usize },
47 #[error("io error: {0}")]
48 Io(#[from] std::io::Error),
49 #[error("json error: {0}")]
50 Json(#[from] serde_json::Error),
51 #[error("chain verification failed: {0}")]
52 Chain(String),
53}
54
55pub fn parse_fixed_hex<const N: usize>(value: &str) -> Result<[u8; N], CliError> {
56 let raw = hex::decode(value).map_err(|e| CliError::InvalidHex(e.to_string()))?;
57 if raw.len() != N {
58 return Err(CliError::InvalidLength {
59 expected: N,
60 actual: raw.len(),
61 });
62 }
63 let mut out = [0u8; N];
64 out.copy_from_slice(&raw);
65 Ok(out)
66}
67
68pub fn sign_record(
69 device_id: String,
70 sequence: u64,
71 timestamp_ms: u64,
72 payload: Vec<u8>,
73 prev_hash: Hash32,
74 object_ref: String,
75 private_key_hex: &str,
76) -> Result<AuditRecord, CliError> {
77 let key_bytes = parse_fixed_hex::<32>(private_key_hex)?;
78 let signing_key = SigningKey::from_bytes(&key_bytes);
79
80 Ok(build_signed_record(
81 device_id,
82 sequence,
83 timestamp_ms,
84 &payload,
85 prev_hash,
86 object_ref,
87 &signing_key,
88 ))
89}
90
91pub fn verify_record(record: &AuditRecord, public_key_hex: &str) -> Result<bool, CliError> {
92 let public_key_bytes = parse_fixed_hex::<32>(public_key_hex)?;
93 let key = VerifyingKey::from_bytes(&public_key_bytes)
94 .map_err(|e| CliError::InvalidHex(e.to_string()))?;
95 Ok(verify_payload_signature(
96 &key,
97 &record.payload_hash,
98 &record.signature,
99 ))
100}
101
102pub fn verify_chain_file(path: &Path) -> Result<(), CliError> {
103 let content = fs::read_to_string(path)?;
104 let records: Vec<AuditRecord> = serde_json::from_str(&content)?;
105 verify_chain(&records).map_err(|e| CliError::Chain(e.to_string()))
106}
107
108pub fn verify_chain_records(records: &[AuditRecord]) -> Result<(), CliError> {
109 verify_chain(records).map_err(|e| CliError::Chain(e.to_string()))
110}
111
112pub fn build_lift_inspection_demo_records_with_payloads(
113 device_id: &str,
114 private_key_hex: &str,
115 start_timestamp_ms: u64,
116 object_prefix: &str,
117) -> Result<Vec<(AuditRecord, Vec<u8>)>, CliError> {
118 let steps = [
119 "check=door,status=ok,open_close_cycle=3",
120 "check=vibration,status=ok,rms=0.18",
121 "check=emergency_brake,status=ok,response_ms=120",
122 ];
123
124 let key_bytes = parse_fixed_hex::<32>(private_key_hex)?;
125 let signing_key = SigningKey::from_bytes(&key_bytes);
126
127 let mut results = Vec::with_capacity(steps.len());
128 let mut prev_hash = AuditRecord::zero_hash();
129
130 for (index, step) in steps.iter().enumerate() {
131 let sequence = (index as u64) + 1;
132 let timestamp_ms = start_timestamp_ms + (index as u64) * 60_000;
133 let payload = format!(
134 "scenario=lift-inspection,device={device_id},sequence={sequence},{step}"
135 )
136 .into_bytes();
137 let object_ref = format!("{object_prefix}/inspection-{sequence}.bin");
138
139 let record = build_signed_record(
140 device_id.to_string(),
141 sequence,
142 timestamp_ms,
143 &payload,
144 prev_hash,
145 object_ref,
146 &signing_key,
147 );
148
149 prev_hash = record.hash();
150 results.push((record, payload));
151 }
152
153 Ok(results)
154}
155
156pub fn build_lift_inspection_demo_records(
157 device_id: &str,
158 private_key_hex: &str,
159 start_timestamp_ms: u64,
160 object_prefix: &str,
161) -> Result<Vec<AuditRecord>, CliError> {
162 let pairs = build_lift_inspection_demo_records_with_payloads(
163 device_id,
164 private_key_hex,
165 start_timestamp_ms,
166 object_prefix,
167 )?;
168 Ok(pairs.into_iter().map(|(r, _)| r).collect())
169}
170
171pub fn write_record_json(path: Option<&Path>, record: &AuditRecord) -> Result<(), CliError> {
172 let json = serde_json::to_string_pretty(record)?;
173 match path {
174 Some(file) => {
175 fs::write(file, json)?;
176 Ok(())
177 }
178 None => {
179 println!("{json}");
180 Ok(())
181 }
182 }
183}
184
185pub fn write_records_json(path: &Path, records: &[AuditRecord]) -> Result<(), CliError> {
186 let json = serde_json::to_string_pretty(records)?;
187 fs::write(path, json)?;
188 Ok(())
189}
190
191#[derive(Debug, Serialize, Deserialize)]
197pub struct KeyPair {
198 pub private_key_hex: String,
199 pub public_key_hex: String,
200}
201
202pub fn generate_keypair() -> KeyPair {
204 let signing_key = SigningKey::generate(&mut OsRng);
205 KeyPair {
206 private_key_hex: hex::encode(signing_key.to_bytes()),
207 public_key_hex: hex::encode(signing_key.verifying_key().to_bytes()),
208 }
209}
210
211pub fn inspect_key(private_key_hex: &str) -> Result<KeyPair, CliError> {
213 let key_bytes = parse_fixed_hex::<32>(private_key_hex)?;
214 let signing_key = SigningKey::from_bytes(&key_bytes);
215 Ok(KeyPair {
216 private_key_hex: hex::encode(signing_key.to_bytes()),
217 public_key_hex: hex::encode(signing_key.verifying_key().to_bytes()),
218 })
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 #[test]
226 fn parse_fixed_hex_requires_exact_length() {
227 let err = parse_fixed_hex::<32>("abcd").unwrap_err();
228 match err {
229 CliError::InvalidLength { expected, actual } => {
230 assert_eq!(expected, 32);
231 assert_eq!(actual, 2);
232 }
233 _ => panic!("unexpected error variant"),
234 }
235 }
236
237 #[test]
238 fn sign_and_verify_record_roundtrip() {
239 let private_key_hex = "0101010101010101010101010101010101010101010101010101010101010101";
240 let private_key = parse_fixed_hex::<32>(private_key_hex).expect("valid private key hex");
241 let signing_key = SigningKey::from_bytes(&private_key);
242 let public_key_hex = hex::encode(signing_key.verifying_key().to_bytes());
243
244 let record = sign_record(
245 "lift-01".to_string(),
246 1,
247 1_700_000_000_000,
248 b"temperature=40".to_vec(),
249 AuditRecord::zero_hash(),
250 "s3://bucket/lift-01/1.bin".to_string(),
251 private_key_hex,
252 )
253 .expect("record should be signed");
254
255 let valid = verify_record(&record, &public_key_hex).expect("verify should run");
256 assert!(valid);
257 }
258
259 #[test]
260 fn build_lift_demo_records_are_chain_valid() {
261 let private_key_hex = "0101010101010101010101010101010101010101010101010101010101010101";
262 let records = build_lift_inspection_demo_records(
263 "lift-01",
264 private_key_hex,
265 1_700_000_000_000,
266 "s3://bucket/lift-01",
267 )
268 .expect("demo records should be generated");
269
270 assert_eq!(records.len(), 3);
271 verify_chain_records(&records).expect("demo chain should be valid");
272 }
273
274 #[test]
275 fn parse_fixed_hex_rejects_invalid_hex_chars() {
276 let invalid = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"; let err = parse_fixed_hex::<32>(invalid).unwrap_err();
278 assert!(matches!(err, CliError::InvalidHex(_)), "expected InvalidHex, got: {err:?}");
279 }
280
281 #[test]
282 fn verify_record_returns_false_for_wrong_public_key() {
283 let private_key_hex = "0202020202020202020202020202020202020202020202020202020202020202";
284 let wrong_key_hex = "0303030303030303030303030303030303030303030303030303030303030303";
285
286 let wrong_signing_key = SigningKey::from_bytes(
287 &parse_fixed_hex::<32>(wrong_key_hex).unwrap()
288 );
289 let wrong_public_key_hex = hex::encode(wrong_signing_key.verifying_key().to_bytes());
290
291 let record = sign_record(
292 "lift-01".to_string(),
293 1,
294 1_700_000_000_000,
295 b"temperature=40".to_vec(),
296 AuditRecord::zero_hash(),
297 "s3://bucket/lift-01/1.bin".to_string(),
298 private_key_hex,
299 )
300 .expect("record should be signed");
301
302 let valid = verify_record(&record, &wrong_public_key_hex).expect("verify should run");
303 assert!(!valid, "wrong public key must not verify the signature");
304 }
305
306 #[test]
307 fn generate_keypair_produces_unique_pairs() {
308 let kp1 = generate_keypair();
309 let kp2 = generate_keypair();
310 assert_ne!(kp1.private_key_hex, kp2.private_key_hex, "each call must produce a unique key");
311 assert_ne!(kp1.public_key_hex, kp2.public_key_hex);
312 assert_eq!(kp1.private_key_hex.len(), 64);
313 assert_eq!(kp1.public_key_hex.len(), 64);
314 }
315
316 #[test]
317 fn inspect_key_roundtrips_with_generate_keypair() {
318 let kp = generate_keypair();
319 let inspected = inspect_key(&kp.private_key_hex).expect("inspect_key should succeed");
320 assert_eq!(kp.private_key_hex, inspected.private_key_hex);
321 assert_eq!(kp.public_key_hex, inspected.public_key_hex);
322 }
323
324 #[test]
325 fn inspect_key_matches_known_public_key() {
326 let private_key_hex = "0101010101010101010101010101010101010101010101010101010101010101";
328 let kp = inspect_key(private_key_hex).expect("inspect_key should succeed");
329 assert_eq!(kp.public_key_hex, "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c");
330 }
331
332 #[test]
333 fn generated_keypair_can_sign_and_verify() {
334 let kp = generate_keypair();
335 let record = sign_record(
336 "lift-gen".to_string(),
337 1,
338 1_700_000_000_000,
339 b"payload".to_vec(),
340 AuditRecord::zero_hash(),
341 "s3://bucket/lift-gen/1.bin".to_string(),
342 &kp.private_key_hex,
343 )
344 .expect("sign_record should succeed with generated key");
345 let valid = verify_record(&record, &kp.public_key_hex).expect("verify should run");
346 assert!(valid, "generated keypair must verify its own signature");
347 }
348
349 #[test]
350 fn tampered_lift_demo_chain_is_detected() {
351 let private_key_hex = "0101010101010101010101010101010101010101010101010101010101010101";
352 let mut records = build_lift_inspection_demo_records(
353 "lift-01",
354 private_key_hex,
355 1_700_000_000_000,
356 "s3://bucket/lift-01",
357 )
358 .expect("demo records should be generated");
359
360 records[0].payload_hash[0] ^= 0xFF;
361
362 let err = verify_chain_records(&records).expect_err("tampered chain must fail");
363 match err {
364 CliError::Chain(message) => {
365 assert!(message.contains("invalid previous hash"));
366 }
367 _ => panic!("unexpected error variant"),
368 }
369 }
370}