Skip to main content

keyhog_profile/
identity.rs

1use crate::{
2    BuildIdentityV2, ConfigIdentityV2, DetectorIdentityV2, Evidence, EvidenceGap, HostIdentityV2,
3    SourceIdentityV2, WorkloadIdentityV2,
4};
5
6const HOST_IDENTITY_VERSION: u16 = 1;
7
8fn unavailable<T>(reason: EvidenceGap) -> Evidence<T> {
9    Evidence::unavailable(reason)
10}
11
12#[cfg(feature = "host-identity")]
13fn read_text(path: &str) -> Result<String, EvidenceGap> {
14    std::fs::read_to_string(path).map_err(|error| {
15        if error.kind() == std::io::ErrorKind::PermissionDenied {
16            EvidenceGap::PermissionDenied
17        } else {
18            EvidenceGap::Unavailable
19        }
20    })
21}
22
23#[cfg(any(feature = "build-identity", feature = "host-identity"))]
24fn digest_text(value: &str) -> String {
25    use sha2::{Digest, Sha256};
26    hex::encode(Sha256::digest(value.as_bytes()))
27}
28
29#[cfg(all(feature = "host-identity", target_os = "linux"))]
30fn linux_cpu_identity() -> (Evidence<String>, Evidence<u32>, Evidence<String>) {
31    let cpuinfo = match read_text("/proc/cpuinfo") {
32        Ok(cpuinfo) => cpuinfo,
33        Err(reason) => {
34            return (
35                unavailable(reason),
36                unavailable(reason),
37                unavailable(reason),
38            )
39        }
40    };
41    let model = cpuinfo
42        .lines()
43        .find_map(|line| {
44            line.split_once(':')
45                .filter(|(key, _)| key.trim() == "model name")
46        })
47        .map(|(_, value)| value.trim().to_owned())
48        .filter(|value| !value.is_empty())
49        .map_or_else(|| unavailable(EvidenceGap::Unavailable), Evidence::recorded);
50
51    let mut cores = std::collections::BTreeSet::new();
52    for processor in cpuinfo.split("\n\n") {
53        let physical = processor.lines().find_map(|line| {
54            line.split_once(':')
55                .filter(|(key, _)| key.trim() == "physical id")
56                .map(|(_, value)| value.trim())
57        });
58        let core = processor.lines().find_map(|line| {
59            line.split_once(':')
60                .filter(|(key, _)| key.trim() == "core id")
61                .map(|(_, value)| value.trim())
62        });
63        if let (Some(physical), Some(core)) = (physical, core) {
64            cores.insert((physical.to_owned(), core.to_owned()));
65        }
66    }
67    let physical_cores = if cores.is_empty() {
68        unavailable(EvidenceGap::Unavailable)
69    } else {
70        Evidence::recorded(u32::try_from(cores.len()).unwrap_or(u32::MAX))
71    };
72
73    let features = cpuinfo
74        .lines()
75        .find_map(|line| {
76            line.split_once(':')
77                .filter(|(key, _)| matches!(key.trim(), "flags" | "Features"))
78                .map(|(_, value)| value)
79        })
80        .map(|features| {
81            let mut features = features.split_whitespace().collect::<Vec<_>>();
82            features.sort_unstable();
83            features.dedup();
84            digest_text(&features.join("\n"))
85        })
86        .map_or_else(|| unavailable(EvidenceGap::Unavailable), Evidence::recorded);
87    (model, physical_cores, features)
88}
89
90#[cfg(all(feature = "host-identity", target_os = "linux"))]
91fn linux_affinity_digest() -> Evidence<String> {
92    match read_text("/proc/self/status") {
93        Ok(status) => status
94            .lines()
95            .find_map(|line| line.strip_prefix("Cpus_allowed_list:"))
96            .map(str::trim)
97            .filter(|value| !value.is_empty())
98            .map(digest_text)
99            .map_or_else(|| unavailable(EvidenceGap::Unavailable), Evidence::recorded),
100        Err(reason) => unavailable(reason),
101    }
102}
103
104#[cfg(all(feature = "host-identity", target_os = "linux"))]
105fn linux_numa_digest() -> Evidence<String> {
106    match read_text("/sys/devices/system/node/online") {
107        Ok(nodes) => {
108            let nodes = nodes.trim();
109            if nodes.is_empty() {
110                unavailable(EvidenceGap::Unavailable)
111            } else {
112                Evidence::recorded(digest_text(nodes))
113            }
114        }
115        Err(reason) => unavailable(reason),
116    }
117}
118
119impl HostIdentityV2 {
120    /// Capture the timing-relevant host identity without recording hostnames or environment values.
121    pub fn capture() -> Self {
122        let logical_cpus = std::thread::available_parallelism()
123            .map(|count| u32::try_from(count.get()).unwrap_or(u32::MAX))
124            .unwrap_or(1);
125        #[cfg(not(feature = "host-identity"))]
126        {
127            let disabled = EvidenceGap::CollectorDisabled;
128            Self {
129                version: HOST_IDENTITY_VERSION,
130                operating_system: Evidence::recorded(std::env::consts::OS.to_owned()),
131                kernel_version: unavailable(disabled),
132                architecture: Evidence::recorded(std::env::consts::ARCH.to_owned()),
133                cpu_model: unavailable(disabled),
134                logical_cpus,
135                physical_cores: unavailable(disabled),
136                cpu_features_digest: unavailable(disabled),
137                affinity_digest: unavailable(disabled),
138                numa_digest: unavailable(disabled),
139            }
140        }
141        #[cfg(all(feature = "host-identity", target_os = "linux"))]
142        {
143            let (cpu_model, physical_cores, cpu_features_digest) = linux_cpu_identity();
144            let kernel_version = match read_text("/proc/sys/kernel/osrelease") {
145                Ok(value) if !value.trim().is_empty() => {
146                    Evidence::recorded(value.trim().to_owned())
147                }
148                Ok(_) => unavailable(EvidenceGap::Unavailable),
149                Err(reason) => unavailable(reason),
150            };
151            Self {
152                version: HOST_IDENTITY_VERSION,
153                operating_system: Evidence::recorded(std::env::consts::OS.to_owned()),
154                kernel_version,
155                architecture: Evidence::recorded(std::env::consts::ARCH.to_owned()),
156                cpu_model,
157                logical_cpus,
158                physical_cores,
159                cpu_features_digest,
160                affinity_digest: linux_affinity_digest(),
161                numa_digest: linux_numa_digest(),
162            }
163        }
164        #[cfg(all(feature = "host-identity", not(target_os = "linux")))]
165        {
166            Self {
167                version: HOST_IDENTITY_VERSION,
168                operating_system: Evidence::recorded(std::env::consts::OS.to_owned()),
169                kernel_version: unavailable(EvidenceGap::Unsupported),
170                architecture: Evidence::recorded(std::env::consts::ARCH.to_owned()),
171                cpu_model: unavailable(EvidenceGap::Unsupported),
172                logical_cpus,
173                physical_cores: unavailable(EvidenceGap::Unsupported),
174                cpu_features_digest: unavailable(EvidenceGap::Unsupported),
175                affinity_digest: unavailable(EvidenceGap::Unsupported),
176                numa_digest: unavailable(EvidenceGap::Unsupported),
177            }
178        }
179    }
180}
181
182/// Detector-specific values supplied after the scanner compiles its effective corpus.
183pub struct DetectorIdentityInput<'a> {
184    pub corpus_digest: &'a str,
185    pub compiled_plan_digest: Option<&'a str>,
186    pub enabled_detector_digest: Option<&'a str>,
187    pub backend_database_digest: Option<&'a str>,
188    pub external_provenance_digest: Option<&'a str>,
189}
190
191impl DetectorIdentityV2 {
192    /// Capture exact corpus and compiled-plan identities with explicit gaps for unavailable peers.
193    pub fn capture(input: DetectorIdentityInput<'_>) -> Self {
194        fn optional(value: Option<&str>) -> Evidence<String> {
195            value
196                .filter(|value| !value.is_empty())
197                .map(|value| Evidence::recorded(value.to_owned()))
198                .unwrap_or_else(|| unavailable(EvidenceGap::Unavailable))
199        }
200
201        Self {
202            version: 1,
203            corpus_digest: input.corpus_digest.to_owned(),
204            compiled_plan_digest: optional(input.compiled_plan_digest),
205            enabled_detector_digest: optional(input.enabled_detector_digest),
206            backend_database_digest: optional(input.backend_database_digest),
207            external_provenance_digest: optional(input.external_provenance_digest),
208        }
209    }
210}
211
212/// Canonical resolved configuration values supplied by the final operator.
213pub struct ConfigIdentityInput<'a> {
214    pub resolved_config_digest: &'a str,
215    pub policy_digest: Option<&'a str>,
216    pub preset: Option<&'a str>,
217    pub protection_state: Option<&'a str>,
218}
219
220impl ConfigIdentityV2 {
221    /// Capture resolved policy identity without storing raw configuration values.
222    pub fn capture(input: ConfigIdentityInput<'_>) -> Self {
223        fn optional(value: Option<&str>) -> Evidence<String> {
224            value
225                .filter(|value| !value.is_empty())
226                .map(|value| Evidence::recorded(value.to_owned()))
227                .unwrap_or_else(|| unavailable(EvidenceGap::Unavailable))
228        }
229
230        Self {
231            version: 1,
232            resolved_config_digest: input.resolved_config_digest.to_owned(),
233            policy_digest: optional(input.policy_digest),
234            preset: optional(input.preset),
235            protection_state: optional(input.protection_state),
236        }
237    }
238}
239
240/// Safe source adapter names and hashed target values supplied by the operator.
241pub struct SourceIdentityInput<'a> {
242    pub adapters: Vec<String>,
243    pub target_digest: Option<&'a str>,
244    pub partition_digest: Option<&'a str>,
245}
246
247impl SourceIdentityV2 {
248    /// Capture source identity while storing no paths, URLs, credentials, or target labels.
249    pub fn capture(input: SourceIdentityInput<'_>) -> Self {
250        fn optional(value: Option<&str>) -> Evidence<String> {
251            value
252                .filter(|value| !value.is_empty())
253                .map(|value| Evidence::recorded(value.to_owned()))
254                .unwrap_or_else(|| unavailable(EvidenceGap::Unavailable))
255        }
256
257        let mut adapters = input.adapters;
258        adapters.sort_unstable();
259        adapters.dedup();
260        Self {
261            version: 1,
262            adapters,
263            target_digest: optional(input.target_digest),
264            partition_digest: optional(input.partition_digest),
265        }
266    }
267}
268
269/// Measured byte and unit totals used to classify comparable workload shapes.
270pub struct WorkloadIdentityInput<'a> {
271    pub class: &'a str,
272    pub raw_source_bytes: u64,
273    pub source_units: u64,
274    pub container_bytes: Option<u64>,
275    pub expanded_payload_bytes: Option<u64>,
276    pub derived_decoder_bytes: Option<u64>,
277    pub backend_dispatched_bytes: Option<u64>,
278}
279
280impl WorkloadIdentityV2 {
281    /// Capture exact byte domains and deterministic size and fanout buckets.
282    pub fn capture(input: WorkloadIdentityInput<'_>) -> Self {
283        fn optional(value: Option<u64>) -> Evidence<u64> {
284            value.map_or_else(|| unavailable(EvidenceGap::Unavailable), Evidence::recorded)
285        }
286        let size_bucket = match input.raw_source_bytes {
287            0 => "empty",
288            1..=4_096 => "tiny",
289            4_097..=1_048_576 => "small",
290            1_048_577..=67_108_864 => "medium",
291            67_108_865..=1_073_741_824 => "large",
292            _ => "huge",
293        };
294        let fanout_bucket = match input.source_units {
295            0 => "empty",
296            1 => "single",
297            2..=16 => "low",
298            17..=1_024 => "medium",
299            _ => "high",
300        };
301        Self {
302            version: 1,
303            class: input.class.to_owned(),
304            raw_source_bytes: input.raw_source_bytes,
305            source_units: input.source_units,
306            container_bytes: optional(input.container_bytes),
307            expanded_payload_bytes: optional(input.expanded_payload_bytes),
308            derived_decoder_bytes: optional(input.derived_decoder_bytes),
309            backend_dispatched_bytes: optional(input.backend_dispatched_bytes),
310            size_bucket: Evidence::recorded(size_bucket.to_owned()),
311            fanout_bucket: Evidence::recorded(fanout_bucket.to_owned()),
312        }
313    }
314}
315
316/// Build-specific values supplied by the final binary crate.
317pub struct BuildIdentityInput<'a> {
318    pub binary_version: &'a str,
319    pub enabled_features: &'a [&'a str],
320    pub allocator: &'a str,
321    pub linked_backends: &'a [(&'a str, &'a str)],
322}
323
324#[cfg(feature = "build-identity")]
325fn digest_current_executable() -> Evidence<String> {
326    use sha2::{Digest, Sha256};
327    use std::io::Read;
328
329    let path = match std::env::current_exe() {
330        Ok(path) => path,
331        Err(_) => return unavailable(EvidenceGap::Unavailable),
332    };
333    let mut file = match std::fs::File::open(path) {
334        Ok(file) => file,
335        Err(error) => {
336            return unavailable(if error.kind() == std::io::ErrorKind::PermissionDenied {
337                EvidenceGap::PermissionDenied
338            } else {
339                EvidenceGap::Unavailable
340            });
341        }
342    };
343    let mut digest = Sha256::new();
344    let mut buffer = [0_u8; 64 * 1024];
345    loop {
346        match file.read(&mut buffer) {
347            Ok(0) => break,
348            Ok(read) => digest.update(&buffer[..read]),
349            Err(error) => {
350                return unavailable(if error.kind() == std::io::ErrorKind::PermissionDenied {
351                    EvidenceGap::PermissionDenied
352                } else {
353                    EvidenceGap::Unavailable
354                });
355            }
356        }
357    }
358    Evidence::recorded(hex::encode(digest.finalize()))
359}
360
361#[cfg(feature = "build-identity")]
362fn canonical_pairs_digest(values: &[(&str, &str)]) -> Evidence<String> {
363    if values.is_empty() {
364        return unavailable(EvidenceGap::Unavailable);
365    }
366    let mut values = values
367        .iter()
368        .map(|(name, version)| format!("{name}={version}"))
369        .collect::<Vec<_>>();
370    values.sort_unstable();
371    values.dedup();
372    Evidence::recorded(digest_text(&values.join("\n")))
373}
374
375impl BuildIdentityV2 {
376    /// Capture the final executable plus feature, allocator, toolchain, and backend identity.
377    pub fn capture(input: BuildIdentityInput<'_>) -> Self {
378        #[cfg(feature = "build-identity")]
379        {
380            let mut features = input.enabled_features.to_vec();
381            features.sort_unstable();
382            features.dedup();
383            let feature_digest = if features.is_empty() {
384                unavailable(EvidenceGap::Unavailable)
385            } else {
386                Evidence::recorded(digest_text(&features.join("\n")))
387            };
388            let source_revision = option_env!("KEYHOG_SOURCE_REVISION")
389                .filter(|value| !value.is_empty())
390                .map(|value| Evidence::recorded(value.to_owned()))
391                .unwrap_or_else(|| unavailable(EvidenceGap::Unavailable));
392            let compiler = env!("KEYHOG_PROFILE_RUSTC");
393            Self {
394                version: 1,
395                binary_version: input.binary_version.to_owned(),
396                binary_digest: digest_current_executable(),
397                source_revision,
398                build_profile: Evidence::recorded(env!("KEYHOG_PROFILE_BUILD_PROFILE").to_owned()),
399                target_triple: Evidence::recorded(env!("KEYHOG_PROFILE_BUILD_TARGET").to_owned()),
400                feature_digest,
401                compiler_identity: if compiler == "unavailable" {
402                    unavailable(EvidenceGap::Unavailable)
403                } else {
404                    Evidence::recorded(compiler.to_owned())
405                },
406                allocator_identity: if input.allocator.is_empty() {
407                    unavailable(EvidenceGap::Unavailable)
408                } else {
409                    Evidence::recorded(input.allocator.to_owned())
410                },
411                linked_backend_digest: canonical_pairs_digest(input.linked_backends),
412            }
413        }
414        #[cfg(not(feature = "build-identity"))]
415        {
416            let disabled = EvidenceGap::CollectorDisabled;
417            Self {
418                version: 1,
419                binary_version: input.binary_version.to_owned(),
420                binary_digest: unavailable(disabled),
421                source_revision: unavailable(disabled),
422                build_profile: unavailable(disabled),
423                target_triple: unavailable(disabled),
424                feature_digest: unavailable(disabled),
425                compiler_identity: unavailable(disabled),
426                allocator_identity: unavailable(disabled),
427                linked_backend_digest: unavailable(disabled),
428            }
429        }
430    }
431
432    pub(crate) fn capture_legacy(binary_version: &str) -> Self {
433        Self::capture(BuildIdentityInput {
434            binary_version,
435            enabled_features: &[],
436            allocator: "",
437            linked_backends: &[],
438        })
439    }
440}