Skip to main content

host_identity/sources/
app_specific.rs

1//! App-specific derivation wrapper.
2//!
3//! Wraps an inner [`Source`] and replaces its probe value with an
4//! `HMAC-SHA256`-derived UUID keyed on the inner value and messaged with
5//! a caller-supplied `app_id`. Two apps on the same host that each
6//! wrap the same inner source with different `app_id`s get
7//! uncorrelatable IDs, and the raw inner value is never exposed on the
8//! resolved [`crate::HostId`].
9//!
10//! This is the generalised form of systemd's
11//! [`sd_id128_get_machine_app_specific()`](https://man.archlinux.org/man/sd_id128_get_machine_app_specific.3.en)
12//! — the privacy property (stable + uncorrelatable across apps + does
13//! not leak the raw key) applies to every stable machine key this
14//! crate abstracts, not just `/etc/machine-id`.
15//!
16//! # Construction
17//!
18//! `HMAC-SHA256(key = inner_value_bytes, msg = app_id)` truncated to
19//! the first 16 bytes, with the RFC 9562 version-4 and variant-10 bits
20//! forced, formatted as a hyphenated UUID string (the same shape as
21//! [`crate::sources::DmiProductUuid`], [`crate::sources::IoPlatformUuid`],
22//! [`crate::sources::WindowsMachineGuid`], and [`crate::sources::KenvSmbios`]).
23//!
24//! Because the output is a UUID string, [`crate::Wrap::Passthrough`]
25//! round-trips the probe unchanged, and the default
26//! [`crate::Wrap::UuidV5Namespaced`] re-hashes it for crate-namespace
27//! separation the same way it does for the other UUID-native sources
28//! — not double-hashing an already-hashed 256-bit value.
29//!
30//! # systemd byte-compat
31//!
32//! `systemd-id128 machine-id --app-specific=<X>` uses the **parsed
33//! 16 raw bytes** of `/etc/machine-id` as the HMAC key and a 16-byte
34//! UUID as the message. The built-in
35//! [`crate::sources::MachineIdFile`] source emits the 32 hex ASCII
36//! characters of the machine-id as its probe value, so
37//! `AppSpecific<MachineIdFile>` HMACs with a 32-byte ASCII key — a
38//! different key than systemd uses. The output UUID shape matches,
39//! but the output bytes do **not**. Callers needing exact byte-compat
40//! must wrap a custom source that emits the 16 raw bytes (e.g. a
41//! [`crate::sources::FnSource`] that reads `/etc/machine-id`, parses
42//! the hex, and returns the 16 bytes as a string of those bytes) and
43//! pass a 16-byte UUID-derived `app_id`. Rust callers that don't care
44//! about systemd interop can pass arbitrary `&[u8]` for `app_id`; the
45//! privacy property (stable + uncorrelatable + non-leaking) holds
46//! regardless of shape.
47//!
48//! # Privacy caveats
49//!
50//! - The inner source's raw value acts as the HMAC key — treat it as
51//!   sensitive. The `hmac` crate holds its own internal copy of the
52//!   key which this crate cannot reach; the `app_id` buffer is
53//!   zeroized on drop as a best-effort mitigation.
54//! - Wrapping an inner source whose raw value is already public
55//!   (cloud instance IDs visible in consoles, Kubernetes pod UIDs
56//!   readable via the API server) adds no privacy — the input isn't
57//!   secret in the first place.
58//! - The derived ID is an identifier, **not** key material. Callers
59//!   must not use it as a cryptographic key.
60
61use std::collections::HashMap;
62use std::fmt;
63use std::sync::{Mutex, OnceLock};
64
65use hmac::{Hmac, Mac};
66use sha2::Sha256;
67use uuid::Uuid;
68use zeroize::Zeroize;
69
70use crate::error::Error;
71use crate::source::{Probe, Source, SourceKind};
72
73type HmacSha256 = Hmac<Sha256>;
74
75/// Wrapper source that HMACs the inner source's probe value with a
76/// caller-supplied `app_id`, emitting a UUID-shaped probe.
77///
78/// # Example
79///
80/// ```no_run
81/// use host_identity::sources::{AppSpecific, MachineIdFile};
82/// use host_identity::{Resolver, Source};
83///
84/// let wrapped = AppSpecific::new(
85///     MachineIdFile::default(),
86///     b"com.example.telemetry".to_vec(),
87/// );
88/// let id = Resolver::new().push(wrapped).resolve()?;
89/// # Ok::<(), host_identity::Error>(())
90/// ```
91pub struct AppSpecific<S: Source> {
92    inner: S,
93    app_id: Vec<u8>,
94    label: &'static str,
95}
96
97impl<S: Source> AppSpecific<S> {
98    /// Wrap `inner` so its probe value is derived with `app_id`.
99    ///
100    /// `app_id` is not secret — privacy comes from not leaking the
101    /// inner source's raw value, not from `app_id` secrecy. Pick a
102    /// stable byte string that identifies your application (reverse
103    /// DNS, a random UUID, a git SHA — whichever is convenient and
104    /// stable across your deployment).
105    ///
106    /// # Label interning
107    ///
108    /// The composed provenance label `app-specific:<inner-id>` is
109    /// interned in a process-global map keyed on the inner source's
110    /// `&'static str` identifier, so memory consumption is bounded by
111    /// the number of **distinct** inner source kinds ever wrapped —
112    /// not the number of `AppSpecific::new` calls. Interned strings
113    /// are leaked for the program's lifetime to satisfy the
114    /// `SourceKind::Custom(&'static str)` contract.
115    #[must_use]
116    pub fn new(inner: S, app_id: impl Into<Vec<u8>>) -> Self {
117        let label = intern_label(inner.kind().as_str());
118        Self {
119            inner,
120            app_id: app_id.into(),
121            label,
122        }
123    }
124}
125
126fn intern_label(inner_id: &'static str) -> &'static str {
127    static INTERNER: OnceLock<Mutex<HashMap<&'static str, &'static str>>> = OnceLock::new();
128    let mut map = INTERNER
129        .get_or_init(|| Mutex::new(HashMap::new()))
130        .lock()
131        .expect("label interner mutex poisoned");
132    if let Some(&existing) = map.get(inner_id) {
133        return existing;
134    }
135    let leaked: &'static str = Box::leak(format!("app-specific:{inner_id}").into_boxed_str());
136    map.insert(inner_id, leaked);
137    leaked
138}
139
140impl<S: Source> fmt::Debug for AppSpecific<S> {
141    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
142        f.debug_struct("AppSpecific")
143            .field("inner", &self.inner.kind())
144            .field("app_id_len", &self.app_id.len())
145            .finish_non_exhaustive()
146    }
147}
148
149impl<S: Source> Drop for AppSpecific<S> {
150    fn drop(&mut self) {
151        self.app_id.zeroize();
152    }
153}
154
155impl<S: Source> Source for AppSpecific<S> {
156    fn kind(&self) -> SourceKind {
157        SourceKind::Custom(self.label)
158    }
159
160    fn probe(&self) -> Result<Option<Probe>, Error> {
161        let Some(probe) = self.inner.probe()? else {
162            return Ok(None);
163        };
164        let (_inner_kind, raw) = probe.into_parts();
165        let uuid = derive_app_specific_uuid(raw.as_bytes(), &self.app_id);
166        Ok(Some(Probe::new(self.kind(), uuid.hyphenated().to_string())))
167    }
168}
169
170/// Compute `HMAC-SHA256(key = raw, msg = app_id)`, truncate to 16 bytes,
171/// force the UUID v4 version and variant-10 bits, and return the UUID.
172fn derive_app_specific_uuid(raw: &[u8], app_id: &[u8]) -> Uuid {
173    let mut mac = HmacSha256::new_from_slice(raw).expect("HMAC-SHA256 accepts keys of any length");
174    mac.update(app_id);
175    let digest = mac.finalize().into_bytes();
176    let mut buf = [0u8; 16];
177    buf.copy_from_slice(&digest[..16]);
178    buf[6] = (buf[6] & 0x0F) | 0x40;
179    buf[8] = (buf[8] & 0x3F) | 0x80;
180    let uuid = Uuid::from_bytes(buf);
181    buf.zeroize();
182    uuid
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use crate::source::{Probe, Source, SourceKind};
189    use crate::wrap::Wrap;
190
191    /// Test stub: returns whatever `value`/`kind` it was built with.
192    #[derive(Debug)]
193    struct Stub {
194        kind: SourceKind,
195        result: Result<Option<String>, &'static str>,
196    }
197
198    impl Stub {
199        fn ok(kind: SourceKind, v: &str) -> Self {
200            Self {
201                kind,
202                result: Ok(Some(v.to_owned())),
203            }
204        }
205        fn none(kind: SourceKind) -> Self {
206            Self {
207                kind,
208                result: Ok(None),
209            }
210        }
211        fn err(kind: SourceKind, msg: &'static str) -> Self {
212            Self {
213                kind,
214                result: Err(msg),
215            }
216        }
217    }
218
219    impl Source for Stub {
220        fn kind(&self) -> SourceKind {
221            self.kind
222        }
223        fn probe(&self) -> Result<Option<Probe>, Error> {
224            match &self.result {
225                Ok(Some(v)) => Ok(Some(Probe::new(self.kind, v.clone()))),
226                Ok(None) => Ok(None),
227                Err(msg) => Err(Error::Malformed {
228                    source_kind: self.kind,
229                    reason: (*msg).to_owned(),
230                }),
231            }
232        }
233    }
234
235    fn probe_value(s: &impl Source) -> String {
236        s.probe().unwrap().unwrap().value().to_owned()
237    }
238
239    #[test]
240    fn output_is_a_valid_version4_uuid() {
241        let wrapped = AppSpecific::new(
242            Stub::ok(SourceKind::MachineId, "abcdef0123456789abcdef0123456789"),
243            b"com.example.test".to_vec(),
244        );
245        let v = probe_value(&wrapped);
246        let parsed = Uuid::parse_str(&v).expect("valid UUID");
247        assert_eq!(parsed.get_version_num(), 4);
248        let variant_byte = parsed.as_bytes()[8];
249        assert_eq!(variant_byte & 0xC0, 0x80, "variant must be 10xx");
250        // Locks hyphenated 8-4-4-4-12 lowercase shape.
251        let re_parts: Vec<_> = v.split('-').collect();
252        assert_eq!(
253            re_parts.iter().map(|p| p.len()).collect::<Vec<_>>(),
254            vec![8, 4, 4, 4, 12]
255        );
256        assert!(v.chars().all(|c| c.is_ascii_hexdigit() || c == '-'));
257    }
258
259    #[test]
260    fn construction_matches_manual_hmac_sha256() {
261        // Regression lock: the wrapper is a thin shim over
262        // `HMAC-SHA256(key=raw, msg=app_id)` with v4/variant-10 bits
263        // forced. This test recomputes the construction by hand and
264        // asserts equality. A failure means the derivation function
265        // drifted from the documented construction — review before
266        // updating.
267        //
268        // The same construction yields byte-compat with
269        // `systemd-id128 machine-id --app-specific=<APP-UUID>` when
270        // `raw` is the /etc/machine-id bytes and `app_id` is the
271        // 16-byte UUID systemd would accept. The test uses a fixture
272        // machine-id and a 16-byte app-id derived from the UUID
273        // "a2b16c2f-0fa0-4d32-b3c3-1ee8c22c0b7e" to exercise that
274        // shape, but does not depend on `systemd-id128` being
275        // installed.
276        let raw = b"abcdef0123456789abcdef0123456789";
277        let app_id: [u8; 16] = [
278            0xa2, 0xb1, 0x6c, 0x2f, 0x0f, 0xa0, 0x4d, 0x32, 0xb3, 0xc3, 0x1e, 0xe8, 0xc2, 0x2c,
279            0x0b, 0x7e,
280        ];
281        let got = derive_app_specific_uuid(raw, &app_id);
282        let mut mac = HmacSha256::new_from_slice(raw).unwrap();
283        mac.update(&app_id);
284        let digest = mac.finalize().into_bytes();
285        let mut buf = [0u8; 16];
286        buf.copy_from_slice(&digest[..16]);
287        buf[6] = (buf[6] & 0x0F) | 0x40;
288        buf[8] = (buf[8] & 0x3F) | 0x80;
289        assert_eq!(got, Uuid::from_bytes(buf));
290        assert_eq!(got.get_version_num(), 4);
291    }
292
293    #[test]
294    fn determinism_over_many_iterations() {
295        let wrapped = AppSpecific::new(
296            Stub::ok(SourceKind::MachineId, "raw-value"),
297            b"app".to_vec(),
298        );
299        let first = probe_value(&wrapped);
300        for _ in 0..100 {
301            assert_eq!(probe_value(&wrapped), first);
302        }
303    }
304
305    #[test]
306    fn different_app_ids_produce_different_outputs() {
307        let a = AppSpecific::new(Stub::ok(SourceKind::MachineId, "x"), b"app-1".to_vec());
308        let b = AppSpecific::new(Stub::ok(SourceKind::MachineId, "x"), b"app-2".to_vec());
309        assert_ne!(probe_value(&a), probe_value(&b));
310    }
311
312    #[test]
313    fn different_inner_values_produce_different_outputs() {
314        let a = AppSpecific::new(Stub::ok(SourceKind::MachineId, "x"), b"app".to_vec());
315        let b = AppSpecific::new(Stub::ok(SourceKind::MachineId, "y"), b"app".to_vec());
316        assert_ne!(probe_value(&a), probe_value(&b));
317    }
318
319    #[test]
320    fn passthrough_wrap_round_trips_the_probe() {
321        let wrapped = AppSpecific::new(Stub::ok(SourceKind::MachineId, "raw"), b"app".to_vec());
322        let v = probe_value(&wrapped);
323        let roundtrip = Wrap::Passthrough.apply(&v).expect("UUID-shaped");
324        assert_eq!(roundtrip, Uuid::parse_str(&v).unwrap());
325    }
326
327    #[test]
328    fn default_wrap_is_stable() {
329        let wrapped = AppSpecific::new(Stub::ok(SourceKind::MachineId, "raw"), b"app".to_vec());
330        let v1 = probe_value(&wrapped);
331        let v2 = probe_value(&wrapped);
332        assert_eq!(
333            Wrap::UuidV5Namespaced.apply(&v1),
334            Wrap::UuidV5Namespaced.apply(&v2),
335        );
336    }
337
338    #[test]
339    fn scope_label_is_app_specific_prefixed() {
340        let wrapped = AppSpecific::new(Stub::ok(SourceKind::MachineId, "raw"), b"app".to_vec());
341        assert_eq!(wrapped.kind().as_str(), "app-specific:machine-id");
342        let probe = wrapped.probe().unwrap().unwrap();
343        assert_eq!(probe.kind().as_str(), "app-specific:machine-id");
344    }
345
346    #[test]
347    fn inner_none_is_passed_through() {
348        let wrapped = AppSpecific::new(Stub::none(SourceKind::MachineId), b"app".to_vec());
349        assert!(wrapped.probe().unwrap().is_none());
350    }
351
352    #[test]
353    fn inner_err_is_passed_through() {
354        let wrapped = AppSpecific::new(Stub::err(SourceKind::MachineId, "boom"), b"app".to_vec());
355        let err = wrapped.probe().expect_err("error must propagate");
356        // The wrapper must surface the inner source's provenance and
357        // reason verbatim rather than re-labelling as app-specific.
358        match err {
359            Error::Malformed {
360                source_kind,
361                reason,
362            } => {
363                assert_eq!(source_kind, SourceKind::MachineId);
364                assert_eq!(reason, "boom");
365            }
366            other => panic!("unexpected error variant: {other:?}"),
367        }
368    }
369
370    #[test]
371    fn label_is_interned_across_constructions() {
372        // Two AppSpecific instances over the same inner kind must
373        // share the same leaked &'static str — memory consumption is
374        // bounded by distinct inner kinds, not by construction count.
375        let a = AppSpecific::new(Stub::ok(SourceKind::MachineId, "x"), b"a".to_vec());
376        let b = AppSpecific::new(Stub::ok(SourceKind::MachineId, "y"), b"b".to_vec());
377        let (SourceKind::Custom(la), SourceKind::Custom(lb)) = (a.kind(), b.kind()) else {
378            panic!("AppSpecific must report SourceKind::Custom");
379        };
380        assert!(std::ptr::eq(la, lb), "label must be interned");
381    }
382
383    #[test]
384    fn empty_inputs_do_not_panic_and_produce_valid_uuids() {
385        // Empty raw, empty app_id.
386        let wrapped = AppSpecific::new(Stub::ok(SourceKind::MachineId, ""), Vec::<u8>::new());
387        let v = probe_value(&wrapped);
388        let parsed = Uuid::parse_str(&v).expect("valid UUID even with empty inputs");
389        assert_eq!(parsed.get_version_num(), 4);
390    }
391}