Skip to main content

edgesentry_rs/
lib.rs

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/// An Ed25519 keypair represented as hex strings.
192///
193/// `private_key_hex` is 32 bytes (64 hex chars) — keep this secret on the device.
194/// `public_key_hex`  is 32 bytes (64 hex chars) — register this on the cloud side
195/// via `IntegrityPolicyGate::register_device`.
196#[derive(Debug, Serialize, Deserialize)]
197pub struct KeyPair {
198    pub private_key_hex: String,
199    pub public_key_hex: String,
200}
201
202/// Generate a fresh Ed25519 keypair using the OS CSPRNG.
203pub 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
211/// Derive the public key from an existing private key hex string.
212pub 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"; // 64 chars but not valid hex
277        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        // This is the well-known test vector used throughout the test suite.
327        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}