Skip to main content

redactor/
lib.rs

1// Copyright 2026 The Fuchsia Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5//! Shared `#![no_std]` in-process redaction and hashing utilities for network
6//! identifiers (MAC addresses, BSSIDs, and SSIDs).
7//!
8//! This crate is `#![no_std]` so that platform-agnostic base crates such as
9//! `net-types` and `ieee80211` can implement [`Redactable`] directly and share
10//! a single process-global [`Redactor`] state machine.
11//!
12//! Responsibilities are split in two:
13//! - A [`Redactor`] only *obscures data*: it maps the 6 octets of a MAC/BSSID
14//!   to 6 octets safe to display, and an SSID to a fixed-size digest. It never
15//!   decides how those bytes are rendered.
16//! - A [`Redactable`] type owns its string representation. It provides its
17//!   raw formatting in [`Redactable::fmt_unredacted`] and formats the bytes
18//!   returned by a [`Redactor`] in [`Redactable::fmt_redacted`]; its
19//!   `Display`/`Debug` impls (see [`impl_display_via_redactable!`]) call
20//!   [`Redactable::fmt_dispatch`], which picks one depending on whether a
21//!   global redactor has been installed.
22//!
23//! By default (such as on `eng` builds, in unit tests, and in host tools), no
24//! global redactor is installed and `fmt_dispatch` writes the raw value.
25//!
26//! When initialized at component startup on `user` and `userdebug` builds:
27//! - [`set_masking_redactor`] synchronously installs [`MaskingRedactor`], which
28//!   preserves the first 3 OUI octets of a MAC and zeroes everything else,
29//!   before the per-boot salt arrives.
30//! - [`set_salted_redactor`] transitions the global redactor to
31//!   [`SaltedRedactor`], which preserves the first 3 OUI octets of a MAC/BSSID
32//!   and replaces the last 3 octets (and the SSID) with a salted SHA-256
33//!   digest keyed by a 32-byte per-boot random salt.
34
35#![deny(missing_docs)]
36#![cfg_attr(not(test), no_std)]
37
38use core::fmt;
39use core::sync::atomic::{AtomicU8, AtomicUsize, Ordering};
40use sha2::{Digest as _, Sha256};
41
42/// Length in bytes of the per-boot random salt.
43pub const SALT_LEN: usize = 32;
44
45/// Length in bytes of an IEEE 802.3 / 802.11 MAC address or BSSID.
46pub const MAC_LEN: usize = 6;
47
48/// Length in bytes of the digest a [`Redactor`] produces for an SSID.
49pub const SSID_DIGEST_LEN: usize = 8;
50
51/// Suffix that [`Redactable::fmt_redacted`] implementations append so a
52/// redacted identifier is distinguishable at a glance from a raw one (e.g.
53/// `00:1a:2b:9f:3e:c1*`). Shared so every crate emits the same marker and
54/// cross-component correlation keeps working.
55pub const REDACTED_MARKER: &str = "*";
56
57/// Domain separation tag for MAC/BSSID hashing.
58const MAC_DOMAIN_TAG: &[u8] = b"fuchsia-diagnostics-redact-mac-v1";
59
60/// Domain separation tag for SSID hashing.
61const SSID_DOMAIN_TAG: &[u8] = b"fuchsia-diagnostics-redact-ssid-v1";
62
63/// States of the global redactor state machine, modeled after
64/// `log::set_logger`.
65///
66/// `State` knows nothing about where it is stored; see [`redactor_state`],
67/// [`set_redactor_state`] and [`maybe_change_redactor_state`] for the global.
68#[repr(usize)]
69#[derive(Clone, Copy, Debug, PartialEq, Eq)]
70enum State {
71    /// No redactor installed; `fmt_dispatch` writes raw values.
72    Uninitialized = 0,
73    /// A writer holds exclusive access to the globals; readers mask.
74    Initializing = 1,
75    /// `MaskingRedactor` is active while the salt is being fetched.
76    Masking = 2,
77    /// A final redactor (`SaltedRedactor` or custom) is installed.
78    Salted = 3,
79}
80
81impl TryFrom<usize> for State {
82    type Error = usize;
83
84    fn try_from(value: usize) -> Result<Self, usize> {
85        match value {
86            0 => Ok(State::Uninitialized),
87            1 => Ok(State::Initializing),
88            2 => Ok(State::Masking),
89            3 => Ok(State::Salted),
90            other => Err(other),
91        }
92    }
93}
94
95/// Backing storage for the global [`State`]. Only ever holds a `State`
96/// discriminant; a `usize` so transitions can be done with atomic
97/// compare-and-swap in `no_std`.
98static REDACTOR_STATE: AtomicUsize = AtomicUsize::new(State::Uninitialized as usize);
99
100/// Reads the global [`State`] with `Acquire` ordering, which pairs with the
101/// `Release` in [`set_redactor_state`] so that anything published before a
102/// state change is visible to readers that observe it.
103fn redactor_state() -> State {
104    State::try_from(REDACTOR_STATE.load(Ordering::Acquire)).expect("invalid redactor state")
105}
106
107/// Unconditionally publishes `state` with `Release` ordering.
108///
109/// Only call this from the writer that currently holds `State::Initializing`.
110fn set_redactor_state(state: State) {
111    REDACTOR_STATE.store(state as usize, Ordering::Release);
112}
113
114/// Atomically moves the global [`State`] from `expected` to `target`.
115/// Returns `false` if the state was not `expected`.
116fn maybe_change_redactor_state(expected: State, target: State) -> bool {
117    REDACTOR_STATE
118        .compare_exchange(expected as usize, target as usize, Ordering::AcqRel, Ordering::Acquire)
119        .is_ok()
120}
121/// The per-boot salt, valid once the state is `Salted`.
122///
123/// Written byte-by-byte by the single writer that holds `Initializing` before
124/// it publishes `Salted` with `Release`; read with `Relaxed` after an
125/// `Acquire` load of the state observed `Salted`, so no `unsafe` or `static
126/// mut` is needed.
127static GLOBAL_SALT: [AtomicU8; SALT_LEN] = [const { AtomicU8::new(0) }; SALT_LEN];
128
129/// Strategy for obscuring MAC addresses, BSSIDs, and SSIDs.
130///
131/// A redactor transforms data; it does not format it. The [`Redactable`] type
132/// that owns the value decides how the returned bytes are rendered.
133pub trait Redactor: Send + Sync {
134    /// Returns the 6 octets that may be displayed in place of `octets`.
135    ///
136    /// Implementations preserve the first 3 octets (the hardware vendor OUI
137    /// prefix) so vendor debugging context survives redaction.
138    fn redact_mac(&self, octets: [u8; MAC_LEN]) -> [u8; MAC_LEN];
139
140    /// Returns a fixed-size digest that may be displayed in place of `ssid`.
141    fn redact_ssid(&self, ssid: &[u8]) -> [u8; SSID_DIGEST_LEN];
142}
143
144/// A [`Redactor`] that preserves the first 3 OUI vendor octets of a MAC and
145/// zeroes the remaining 3 octets, and zeroes SSID digests. Used in the window
146/// between component startup and the per-boot salt becoming available.
147#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
148pub struct MaskingRedactor;
149
150impl Redactor for MaskingRedactor {
151    fn redact_mac(&self, octets: [u8; MAC_LEN]) -> [u8; MAC_LEN] {
152        [octets[0], octets[1], octets[2], 0, 0, 0]
153    }
154
155    fn redact_ssid(&self, _ssid: &[u8]) -> [u8; SSID_DIGEST_LEN] {
156        [0; SSID_DIGEST_LEN]
157    }
158}
159
160/// A [`Redactor`] that hashes identifiers using a 32-byte per-boot secret salt.
161///
162/// For MAC addresses and BSSIDs, the first 3 octets (hardware vendor OUI
163/// prefix) are preserved and the last 3 octets are replaced with the first
164/// 3 octets of the salted SHA-256 digest of the full 6-octet address.
165#[derive(Clone, Copy, Debug, PartialEq, Eq)]
166pub struct SaltedRedactor {
167    salt: [u8; SALT_LEN],
168}
169
170impl SaltedRedactor {
171    /// Creates a new [`SaltedRedactor`] with the given 32-byte `salt`.
172    pub const fn new(salt: [u8; SALT_LEN]) -> Self {
173        Self { salt }
174    }
175
176    /// Returns a reference to the 32-byte salt.
177    pub const fn salt(&self) -> &[u8; SALT_LEN] {
178        &self.salt
179    }
180}
181
182impl Redactor for SaltedRedactor {
183    fn redact_mac(&self, octets: [u8; MAC_LEN]) -> [u8; MAC_LEN] {
184        let mut hasher = Sha256::new();
185        hasher.update(MAC_DOMAIN_TAG);
186        hasher.update(self.salt);
187        hasher.update(octets);
188        let digest = hasher.finalize();
189        [octets[0], octets[1], octets[2], digest[0], digest[1], digest[2]]
190    }
191
192    fn redact_ssid(&self, ssid: &[u8]) -> [u8; SSID_DIGEST_LEN] {
193        let mut hasher = Sha256::new();
194        hasher.update(SSID_DOMAIN_TAG);
195        hasher.update(self.salt);
196        hasher.update(ssid);
197        let digest = hasher.finalize();
198        let mut out = [0; SSID_DIGEST_LEN];
199        out.copy_from_slice(&digest[..SSID_DIGEST_LEN]);
200        out
201    }
202}
203
204/// Trait implemented by identifier types (such as MAC addresses, BSSIDs, and
205/// SSIDs) whose `Display` and `Debug` representations support redaction via the
206/// global [`Redactor`].
207///
208/// Implementors provide the raw formatting in [`Redactable::fmt_unredacted`]
209/// and the redacted formatting in [`Redactable::fmt_redacted`]; their
210/// `Display`/`Debug` impls should call [`Redactable::fmt_dispatch`], which
211/// picks one based on whether a global redactor has been installed.
212pub trait Redactable {
213    /// Formats the raw, unredacted value of `self`.
214    ///
215    /// Used when no global redactor is installed (e.g., on `eng` builds, in
216    /// unit tests, and in host tools).
217    fn fmt_unredacted(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result;
218
219    /// Formats `self` after obscuring it with the provided [`Redactor`].
220    ///
221    /// Implementations should pass their data through [`Redactor::redact_mac`]
222    /// or [`Redactor::redact_ssid`], render the result the same way
223    /// [`Redactable::fmt_unredacted`] renders the raw data, and append
224    /// [`REDACTED_MARKER`].
225    fn fmt_redacted(&self, redactor: &dyn Redactor, f: &mut fmt::Formatter<'_>) -> fmt::Result;
226
227    /// Formats `self` through the active global [`Redactor`] if one has been
228    /// installed, or via [`Redactable::fmt_unredacted`] otherwise.
229    fn fmt_dispatch(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230        match redactor_state() {
231            State::Uninitialized => self.fmt_unredacted(f),
232            State::Masking | State::Initializing => self.fmt_redacted(&MaskingRedactor, f),
233            State::Salted => self.fmt_redacted(&SaltedRedactor::new(load_salt()), f),
234        }
235    }
236}
237
238/// A `Display` adapter that always formats a [`Redactable`] value in its raw,
239/// unredacted form, bypassing the global redactor.
240///
241/// Intended for the rare places where the string is *data* rather than
242/// diagnostics (e.g. serialization to persistent storage or a wire format)
243/// and must round-trip. Do not use it for logs or Inspect.
244pub struct Unredacted<'a, T: ?Sized>(pub &'a T);
245
246impl<T: Redactable + ?Sized> fmt::Display for Unredacted<'_, T> {
247    #[inline]
248    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
249        self.0.fmt_unredacted(f)
250    }
251}
252
253/// Error returned when attempting to install a final global [`Redactor`] after
254/// one has already been installed.
255#[derive(Clone, Copy, Debug, PartialEq, Eq)]
256pub struct SetRedactorError;
257
258impl fmt::Display for SetRedactorError {
259    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
260        write!(f, "a global redactor has already been installed")
261    }
262}
263
264impl core::error::Error for SetRedactorError {}
265
266/// Synchronously transitions the global redactor from `Uninitialized` to
267/// [`MaskingRedactor`] while the per-boot salt is being fetched asynchronously.
268///
269/// If a final redactor has already been installed, this is a no-op.
270pub fn set_masking_redactor() {
271    let _ = maybe_change_redactor_state(State::Uninitialized, State::Masking);
272}
273
274/// Installs a [`SaltedRedactor`] with the given `salt` as the global redactor.
275///
276/// Supports transitioning from either `Uninitialized` or `Masking` (after
277/// [`set_masking_redactor`]).
278///
279/// # Errors
280///
281/// Returns [`SetRedactorError`] if a salted redactor has already been
282/// installed.
283pub fn set_salted_redactor(salt: [u8; SALT_LEN]) -> Result<(), SetRedactorError> {
284    loop {
285        let current = redactor_state();
286        if current != State::Uninitialized && current != State::Masking {
287            return Err(SetRedactorError);
288        }
289        if maybe_change_redactor_state(current, State::Initializing) {
290            // We are the only writer while the state is `Initializing`;
291            // readers mask in the meantime and only read `GLOBAL_SALT` after
292            // observing `Salted` (published with `Release` below).
293            for (dst, src) in GLOBAL_SALT.iter().zip(salt) {
294                dst.store(src, Ordering::Relaxed);
295            }
296            set_redactor_state(State::Salted);
297            return Ok(());
298        }
299    }
300}
301
302/// Reads the salt published by [`set_salted_redactor`]. Only meaningful after
303/// [`redactor_state`] returned `Salted`, whose `Acquire` load pairs with the
304/// `Release` store that published the salt.
305fn load_salt() -> [u8; SALT_LEN] {
306    core::array::from_fn(|i| GLOBAL_SALT[i].load(Ordering::Relaxed))
307}
308
309/// Implements `core::fmt::Display` for a type that implements [`Redactable`]
310/// by delegating to [`Redactable::fmt_dispatch`].
311///
312/// Using this macro (rather than a hand-written `Display`) guarantees every
313/// `{}` of the type goes through the global redactor.
314#[macro_export]
315macro_rules! impl_display_via_redactable {
316    ($ty:ty) => {
317        impl ::core::fmt::Display for $ty {
318            #[inline]
319            fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
320                $crate::Redactable::fmt_dispatch(self, f)
321            }
322        }
323    };
324}
325
326/// Implements `core::fmt::Debug` for a type that implements [`Redactable`]
327/// by delegating to [`Redactable::fmt_dispatch`], so `{:?}` produces the same
328/// (possibly redacted) output as `{}`.
329#[macro_export]
330macro_rules! impl_debug_via_redactable {
331    ($ty:ty) => {
332        impl ::core::fmt::Debug for $ty {
333            #[inline]
334            fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
335                $crate::Redactable::fmt_dispatch(self, f)
336            }
337        }
338    };
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    const TEST_SALT_1: [u8; SALT_LEN] = [0x11; SALT_LEN];
346    const TEST_SALT_2: [u8; SALT_LEN] = [0x22; SALT_LEN];
347    const TEST_MAC_1: [u8; MAC_LEN] = [0x00, 0x1a, 0x2b, 0x3c, 0x4d, 0x5e];
348    const TEST_MAC_2: [u8; MAC_LEN] = [0x00, 0x1a, 0x2b, 0xff, 0xee, 0xdd];
349
350    struct TestMac([u8; MAC_LEN]);
351
352    fn fmt_mac(m: [u8; MAC_LEN], f: &mut fmt::Formatter<'_>) -> fmt::Result {
353        write!(f, "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", m[0], m[1], m[2], m[3], m[4], m[5])
354    }
355
356    impl Redactable for TestMac {
357        fn fmt_unredacted(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
358            fmt_mac(self.0, f)
359        }
360
361        fn fmt_redacted(&self, r: &dyn Redactor, f: &mut fmt::Formatter<'_>) -> fmt::Result {
362            fmt_mac(r.redact_mac(self.0), f)?;
363            f.write_str(REDACTED_MARKER)
364        }
365    }
366
367    crate::impl_display_via_redactable!(TestMac);
368    crate::impl_debug_via_redactable!(TestMac);
369
370    struct TestSsid<'a>(&'a [u8]);
371
372    fn fmt_ssid(bytes: &[u8], f: &mut fmt::Formatter<'_>) -> fmt::Result {
373        write!(f, "<ssid-")?;
374        for byte in bytes {
375            write!(f, "{byte:02x}")?;
376        }
377        write!(f, ">")
378    }
379
380    impl Redactable for TestSsid<'_> {
381        fn fmt_unredacted(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
382            fmt_ssid(self.0, f)
383        }
384
385        fn fmt_redacted(&self, r: &dyn Redactor, f: &mut fmt::Formatter<'_>) -> fmt::Result {
386            fmt_ssid(&r.redact_ssid(self.0), f)?;
387            f.write_str(REDACTED_MARKER)
388        }
389    }
390
391    crate::impl_display_via_redactable!(TestSsid<'_>);
392
393    fn format_with_redactor<T: Redactable>(item: &T, r: &dyn Redactor) -> String {
394        struct Adapter<'a, T>(&'a T, &'a dyn Redactor);
395        impl<T: Redactable> fmt::Display for Adapter<'_, T> {
396            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
397                self.0.fmt_redacted(self.1, f)
398            }
399        }
400        format!("{}", Adapter(item, r))
401    }
402
403    fn format_unredacted<T: Redactable>(item: &T) -> String {
404        struct Adapter<'a, T>(&'a T);
405        impl<T: Redactable> fmt::Display for Adapter<'_, T> {
406            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
407                self.0.fmt_unredacted(f)
408            }
409        }
410        format!("{}", Adapter(item))
411    }
412
413    #[test]
414    fn masking_redactor_preserves_oui_and_zeroes_suffix_and_ssid() {
415        assert_eq!(MaskingRedactor.redact_mac(TEST_MAC_1), [0x00, 0x1a, 0x2b, 0, 0, 0]);
416        assert_eq!(MaskingRedactor.redact_ssid(b"foo"), [0; SSID_DIGEST_LEN]);
417        assert_eq!(
418            format_with_redactor(&TestMac(TEST_MAC_1), &MaskingRedactor),
419            "00:1a:2b:00:00:00*"
420        );
421        assert_eq!(
422            format_with_redactor(&TestSsid(b"foo"), &MaskingRedactor),
423            "<ssid-0000000000000000>*"
424        );
425    }
426
427    #[test]
428    fn salted_redactor_preserves_oui_and_hashes_nic() {
429        let r1 = SaltedRedactor::new(TEST_SALT_1);
430        let r2 = SaltedRedactor::new(TEST_SALT_2);
431        assert_eq!(r1.salt(), &TEST_SALT_1);
432
433        let hashed_1 = format_with_redactor(&TestMac(TEST_MAC_1), &r1);
434        assert!(hashed_1.starts_with("00:1a:2b:"));
435        assert!(hashed_1.ends_with(REDACTED_MARKER));
436        assert_eq!(hashed_1.len(), "00:1a:2b:3c:4d:5e*".len());
437        assert_ne!(hashed_1, "00:1a:2b:3c:4d:5e*");
438
439        // Deterministic for the same salt and MAC.
440        assert_eq!(hashed_1, format_with_redactor(&TestMac(TEST_MAC_1), &r1));
441
442        // Different MAC with the same OUI has the same OUI prefix but a different hashed NIC.
443        let hashed_2 = format_with_redactor(&TestMac(TEST_MAC_2), &r1);
444        assert!(hashed_2.starts_with("00:1a:2b:"));
445        assert_ne!(hashed_1, hashed_2);
446
447        // Different salt produces a different hashed NIC.
448        let hashed_diff_salt = format_with_redactor(&TestMac(TEST_MAC_1), &r2);
449        assert!(hashed_diff_salt.starts_with("00:1a:2b:"));
450        assert_ne!(hashed_1, hashed_diff_salt);
451    }
452
453    #[test]
454    fn salted_redactor_hashes_ssid_deterministically() {
455        let r1 = SaltedRedactor::new(TEST_SALT_1);
456        let r2 = SaltedRedactor::new(TEST_SALT_2);
457
458        let ssid_1 = TestSsid(b"CorpWiFi");
459        let ssid_2 = TestSsid(b"GuestWiFi");
460
461        let hashed_1 = format_with_redactor(&ssid_1, &r1);
462        assert!(hashed_1.starts_with("<ssid-"));
463        assert!(hashed_1.ends_with(">*"));
464        assert_ne!(hashed_1, format_unredacted(&ssid_1));
465        assert_eq!(hashed_1, format_with_redactor(&ssid_1, &r1));
466        assert_ne!(hashed_1, format_with_redactor(&ssid_2, &r1));
467        assert_ne!(hashed_1, format_with_redactor(&ssid_1, &r2));
468    }
469
470    // The redactor state is process-global and Rust runs tests in parallel in
471    // one process, so everything that touches the global lives in this single
472    // test. Do not add a second test that calls `set_*_redactor`.
473    #[test]
474    fn global_state_machine_transitions() {
475        // Uninitialized -> Masking -> Salted.
476        set_redactor_state(State::Uninitialized);
477        let mac = TestMac(TEST_MAC_1);
478        let ssid = TestSsid(b"CorpWiFi");
479
480        // Uninitialized -> dispatch writes the type's own unredacted format.
481        assert_eq!(redactor_state(), State::Uninitialized);
482        assert_eq!(format!("{mac}"), "00:1a:2b:3c:4d:5e");
483        assert_eq!(format!("{mac:?}"), "00:1a:2b:3c:4d:5e");
484        assert_eq!(format!("{ssid}"), "<ssid-436f727057694669>");
485
486        // Transition to MASKING.
487        set_masking_redactor();
488        assert_eq!(redactor_state(), State::Masking);
489        assert_eq!(format!("{mac}"), "00:1a:2b:00:00:00*");
490        assert_eq!(format!("{ssid}"), "<ssid-0000000000000000>*");
491
492        // Transition from MASKING to SALTED.
493        assert_eq!(set_salted_redactor(TEST_SALT_1), Ok(()));
494        let expected_mac = format_with_redactor(&mac, &SaltedRedactor::new(TEST_SALT_1));
495        let expected_ssid = format_with_redactor(&ssid, &SaltedRedactor::new(TEST_SALT_1));
496        assert_eq!(format!("{mac}"), expected_mac);
497        assert_eq!(format!("{ssid}"), expected_ssid);
498
499        // Once SALTED, further calls to set_salted_redactor or set_masking_redactor do not overwrite.
500        assert_eq!(set_salted_redactor(TEST_SALT_2), Err(SetRedactorError));
501        set_masking_redactor();
502        assert_eq!(format!("{mac}"), expected_mac);
503
504        // Direct Uninitialized -> Salted (no masking step) is also supported.
505        set_redactor_state(State::Uninitialized);
506        assert_eq!(format!("{mac}"), "00:1a:2b:3c:4d:5e");
507        assert_eq!(set_salted_redactor(TEST_SALT_2), Ok(()));
508        assert_eq!(format!("{mac}"), format_with_redactor(&mac, &SaltedRedactor::new(TEST_SALT_2)));
509    }
510}