blob: 9e9b19c3cce4426d09e3706cae7070d97ece71ad [file]
// Copyright 2026 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
//! Shared `#![no_std]` in-process redaction and hashing utilities for network
//! identifiers (MAC addresses, BSSIDs, and SSIDs).
//!
//! This crate is `#![no_std]` so that platform-agnostic base crates such as
//! `net-types` and `ieee80211` can implement [`Redactable`] directly and share
//! a single process-global [`Redactor`] state machine.
//!
//! Responsibilities are split in two:
//! - A [`Redactor`] only *obscures data*: it maps the 6 octets of a MAC/BSSID
//! to 6 octets safe to display, and an SSID to a fixed-size digest. It never
//! decides how those bytes are rendered.
//! - A [`Redactable`] type owns its string representation. It provides its
//! raw formatting in [`Redactable::fmt_unredacted`] and formats the bytes
//! returned by a [`Redactor`] in [`Redactable::fmt_redacted`]; its
//! `Display`/`Debug` impls (see [`impl_display_via_redactable!`]) call
//! [`Redactable::fmt_dispatch`], which picks one depending on whether a
//! global redactor has been installed.
//!
//! By default (such as on `eng` builds, in unit tests, and in host tools), no
//! global redactor is installed and `fmt_dispatch` writes the raw value.
//!
//! When initialized at component startup on `user` and `userdebug` builds:
//! - [`set_masking_redactor`] synchronously installs [`MaskingRedactor`], which
//! preserves the first 3 OUI octets of a MAC and zeroes everything else,
//! before the per-boot salt arrives.
//! - [`set_salted_redactor`] transitions the global redactor to
//! [`SaltedRedactor`], which preserves the first 3 OUI octets of a MAC/BSSID
//! and replaces the last 3 octets (and the SSID) with a salted SHA-256
//! digest keyed by a 32-byte per-boot random salt.
#![deny(missing_docs)]
#![cfg_attr(not(test), no_std)]
use core::fmt;
use core::sync::atomic::{AtomicU8, AtomicUsize, Ordering};
use sha2::{Digest as _, Sha256};
/// Length in bytes of the per-boot random salt.
pub const SALT_LEN: usize = 32;
/// Length in bytes of an IEEE 802.3 / 802.11 MAC address or BSSID.
pub const MAC_LEN: usize = 6;
/// Length in bytes of the digest a [`Redactor`] produces for an SSID.
pub const SSID_DIGEST_LEN: usize = 8;
/// Suffix that [`Redactable::fmt_redacted`] implementations append so a
/// redacted identifier is distinguishable at a glance from a raw one (e.g.
/// `00:1a:2b:9f:3e:c1*`). Shared so every crate emits the same marker and
/// cross-component correlation keeps working.
pub const REDACTED_MARKER: &str = "*";
/// Domain separation tag for MAC/BSSID hashing.
const MAC_DOMAIN_TAG: &[u8] = b"fuchsia-diagnostics-redact-mac-v1";
/// Domain separation tag for SSID hashing.
const SSID_DOMAIN_TAG: &[u8] = b"fuchsia-diagnostics-redact-ssid-v1";
/// States of the global redactor state machine, modeled after
/// `log::set_logger`.
///
/// `State` knows nothing about where it is stored; see [`redactor_state`],
/// [`set_redactor_state`] and [`maybe_change_redactor_state`] for the global.
#[repr(usize)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum State {
/// No redactor installed; `fmt_dispatch` writes raw values.
Uninitialized = 0,
/// A writer holds exclusive access to the globals; readers mask.
Initializing = 1,
/// `MaskingRedactor` is active while the salt is being fetched.
Masking = 2,
/// A final redactor (`SaltedRedactor` or custom) is installed.
Salted = 3,
}
impl TryFrom<usize> for State {
type Error = usize;
fn try_from(value: usize) -> Result<Self, usize> {
match value {
0 => Ok(State::Uninitialized),
1 => Ok(State::Initializing),
2 => Ok(State::Masking),
3 => Ok(State::Salted),
other => Err(other),
}
}
}
/// Backing storage for the global [`State`]. Only ever holds a `State`
/// discriminant; a `usize` so transitions can be done with atomic
/// compare-and-swap in `no_std`.
static REDACTOR_STATE: AtomicUsize = AtomicUsize::new(State::Uninitialized as usize);
/// Reads the global [`State`] with `Acquire` ordering, which pairs with the
/// `Release` in [`set_redactor_state`] so that anything published before a
/// state change is visible to readers that observe it.
fn redactor_state() -> State {
State::try_from(REDACTOR_STATE.load(Ordering::Acquire)).expect("invalid redactor state")
}
/// Unconditionally publishes `state` with `Release` ordering.
///
/// Only call this from the writer that currently holds `State::Initializing`.
fn set_redactor_state(state: State) {
REDACTOR_STATE.store(state as usize, Ordering::Release);
}
/// Atomically moves the global [`State`] from `expected` to `target`.
/// Returns `false` if the state was not `expected`.
fn maybe_change_redactor_state(expected: State, target: State) -> bool {
REDACTOR_STATE
.compare_exchange(expected as usize, target as usize, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
}
/// The per-boot salt, valid once the state is `Salted`.
///
/// Written byte-by-byte by the single writer that holds `Initializing` before
/// it publishes `Salted` with `Release`; read with `Relaxed` after an
/// `Acquire` load of the state observed `Salted`, so no `unsafe` or `static
/// mut` is needed.
static GLOBAL_SALT: [AtomicU8; SALT_LEN] = [const { AtomicU8::new(0) }; SALT_LEN];
/// Strategy for obscuring MAC addresses, BSSIDs, and SSIDs.
///
/// A redactor transforms data; it does not format it. The [`Redactable`] type
/// that owns the value decides how the returned bytes are rendered.
pub trait Redactor: Send + Sync {
/// Returns the 6 octets that may be displayed in place of `octets`.
///
/// Implementations preserve the first 3 octets (the hardware vendor OUI
/// prefix) so vendor debugging context survives redaction.
fn redact_mac(&self, octets: [u8; MAC_LEN]) -> [u8; MAC_LEN];
/// Returns a fixed-size digest that may be displayed in place of `ssid`.
fn redact_ssid(&self, ssid: &[u8]) -> [u8; SSID_DIGEST_LEN];
}
/// A [`Redactor`] that preserves the first 3 OUI vendor octets of a MAC and
/// zeroes the remaining 3 octets, and zeroes SSID digests. Used in the window
/// between component startup and the per-boot salt becoming available.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct MaskingRedactor;
impl Redactor for MaskingRedactor {
fn redact_mac(&self, octets: [u8; MAC_LEN]) -> [u8; MAC_LEN] {
[octets[0], octets[1], octets[2], 0, 0, 0]
}
fn redact_ssid(&self, _ssid: &[u8]) -> [u8; SSID_DIGEST_LEN] {
[0; SSID_DIGEST_LEN]
}
}
/// A [`Redactor`] that hashes identifiers using a 32-byte per-boot secret salt.
///
/// For MAC addresses and BSSIDs, the first 3 octets (hardware vendor OUI
/// prefix) are preserved and the last 3 octets are replaced with the first
/// 3 octets of the salted SHA-256 digest of the full 6-octet address.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SaltedRedactor {
salt: [u8; SALT_LEN],
}
impl SaltedRedactor {
/// Creates a new [`SaltedRedactor`] with the given 32-byte `salt`.
pub const fn new(salt: [u8; SALT_LEN]) -> Self {
Self { salt }
}
/// Returns a reference to the 32-byte salt.
pub const fn salt(&self) -> &[u8; SALT_LEN] {
&self.salt
}
}
impl Redactor for SaltedRedactor {
fn redact_mac(&self, octets: [u8; MAC_LEN]) -> [u8; MAC_LEN] {
let mut hasher = Sha256::new();
hasher.update(MAC_DOMAIN_TAG);
hasher.update(self.salt);
hasher.update(octets);
let digest = hasher.finalize();
[octets[0], octets[1], octets[2], digest[0], digest[1], digest[2]]
}
fn redact_ssid(&self, ssid: &[u8]) -> [u8; SSID_DIGEST_LEN] {
let mut hasher = Sha256::new();
hasher.update(SSID_DOMAIN_TAG);
hasher.update(self.salt);
hasher.update(ssid);
let digest = hasher.finalize();
let mut out = [0; SSID_DIGEST_LEN];
out.copy_from_slice(&digest[..SSID_DIGEST_LEN]);
out
}
}
/// Trait implemented by identifier types (such as MAC addresses, BSSIDs, and
/// SSIDs) whose `Display` and `Debug` representations support redaction via the
/// global [`Redactor`].
///
/// Implementors provide the raw formatting in [`Redactable::fmt_unredacted`]
/// and the redacted formatting in [`Redactable::fmt_redacted`]; their
/// `Display`/`Debug` impls should call [`Redactable::fmt_dispatch`], which
/// picks one based on whether a global redactor has been installed.
pub trait Redactable {
/// Formats the raw, unredacted value of `self`.
///
/// Used when no global redactor is installed (e.g., on `eng` builds, in
/// unit tests, and in host tools).
fn fmt_unredacted(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result;
/// Formats `self` after obscuring it with the provided [`Redactor`].
///
/// Implementations should pass their data through [`Redactor::redact_mac`]
/// or [`Redactor::redact_ssid`], render the result the same way
/// [`Redactable::fmt_unredacted`] renders the raw data, and append
/// [`REDACTED_MARKER`].
fn fmt_redacted(&self, redactor: &dyn Redactor, f: &mut fmt::Formatter<'_>) -> fmt::Result;
/// Formats `self` through the active global [`Redactor`] if one has been
/// installed, or via [`Redactable::fmt_unredacted`] otherwise.
fn fmt_dispatch(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match redactor_state() {
State::Uninitialized => self.fmt_unredacted(f),
State::Masking | State::Initializing => self.fmt_redacted(&MaskingRedactor, f),
State::Salted => self.fmt_redacted(&SaltedRedactor::new(load_salt()), f),
}
}
}
/// A `Display` adapter that always formats a [`Redactable`] value in its raw,
/// unredacted form, bypassing the global redactor.
///
/// Intended for the rare places where the string is *data* rather than
/// diagnostics (e.g. serialization to persistent storage or a wire format)
/// and must round-trip. Do not use it for logs or Inspect.
pub struct Unredacted<'a, T: ?Sized>(pub &'a T);
impl<T: Redactable + ?Sized> fmt::Display for Unredacted<'_, T> {
#[inline]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt_unredacted(f)
}
}
/// Error returned when attempting to install a final global [`Redactor`] after
/// one has already been installed.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SetRedactorError;
impl fmt::Display for SetRedactorError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "a global redactor has already been installed")
}
}
impl core::error::Error for SetRedactorError {}
/// Synchronously transitions the global redactor from `Uninitialized` to
/// [`MaskingRedactor`] while the per-boot salt is being fetched asynchronously.
///
/// If a final redactor has already been installed, this is a no-op.
pub fn set_masking_redactor() {
let _ = maybe_change_redactor_state(State::Uninitialized, State::Masking);
}
/// Installs a [`SaltedRedactor`] with the given `salt` as the global redactor.
///
/// Supports transitioning from either `Uninitialized` or `Masking` (after
/// [`set_masking_redactor`]).
///
/// # Errors
///
/// Returns [`SetRedactorError`] if a salted redactor has already been
/// installed.
pub fn set_salted_redactor(salt: [u8; SALT_LEN]) -> Result<(), SetRedactorError> {
loop {
let current = redactor_state();
if current != State::Uninitialized && current != State::Masking {
return Err(SetRedactorError);
}
if maybe_change_redactor_state(current, State::Initializing) {
// We are the only writer while the state is `Initializing`;
// readers mask in the meantime and only read `GLOBAL_SALT` after
// observing `Salted` (published with `Release` below).
for (dst, src) in GLOBAL_SALT.iter().zip(salt) {
dst.store(src, Ordering::Relaxed);
}
set_redactor_state(State::Salted);
return Ok(());
}
}
}
/// Reads the salt published by [`set_salted_redactor`]. Only meaningful after
/// [`redactor_state`] returned `Salted`, whose `Acquire` load pairs with the
/// `Release` store that published the salt.
fn load_salt() -> [u8; SALT_LEN] {
core::array::from_fn(|i| GLOBAL_SALT[i].load(Ordering::Relaxed))
}
/// Implements `core::fmt::Display` for a type that implements [`Redactable`]
/// by delegating to [`Redactable::fmt_dispatch`].
///
/// Using this macro (rather than a hand-written `Display`) guarantees every
/// `{}` of the type goes through the global redactor.
#[macro_export]
macro_rules! impl_display_via_redactable {
($ty:ty) => {
impl ::core::fmt::Display for $ty {
#[inline]
fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
$crate::Redactable::fmt_dispatch(self, f)
}
}
};
}
/// Implements `core::fmt::Debug` for a type that implements [`Redactable`]
/// by delegating to [`Redactable::fmt_dispatch`], so `{:?}` produces the same
/// (possibly redacted) output as `{}`.
#[macro_export]
macro_rules! impl_debug_via_redactable {
($ty:ty) => {
impl ::core::fmt::Debug for $ty {
#[inline]
fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
$crate::Redactable::fmt_dispatch(self, f)
}
}
};
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_SALT_1: [u8; SALT_LEN] = [0x11; SALT_LEN];
const TEST_SALT_2: [u8; SALT_LEN] = [0x22; SALT_LEN];
const TEST_MAC_1: [u8; MAC_LEN] = [0x00, 0x1a, 0x2b, 0x3c, 0x4d, 0x5e];
const TEST_MAC_2: [u8; MAC_LEN] = [0x00, 0x1a, 0x2b, 0xff, 0xee, 0xdd];
struct TestMac([u8; MAC_LEN]);
fn fmt_mac(m: [u8; MAC_LEN], f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", m[0], m[1], m[2], m[3], m[4], m[5])
}
impl Redactable for TestMac {
fn fmt_unredacted(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt_mac(self.0, f)
}
fn fmt_redacted(&self, r: &dyn Redactor, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt_mac(r.redact_mac(self.0), f)?;
f.write_str(REDACTED_MARKER)
}
}
crate::impl_display_via_redactable!(TestMac);
crate::impl_debug_via_redactable!(TestMac);
struct TestSsid<'a>(&'a [u8]);
fn fmt_ssid(bytes: &[u8], f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "<ssid-")?;
for byte in bytes {
write!(f, "{byte:02x}")?;
}
write!(f, ">")
}
impl Redactable for TestSsid<'_> {
fn fmt_unredacted(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt_ssid(self.0, f)
}
fn fmt_redacted(&self, r: &dyn Redactor, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt_ssid(&r.redact_ssid(self.0), f)?;
f.write_str(REDACTED_MARKER)
}
}
crate::impl_display_via_redactable!(TestSsid<'_>);
fn format_with_redactor<T: Redactable>(item: &T, r: &dyn Redactor) -> String {
struct Adapter<'a, T>(&'a T, &'a dyn Redactor);
impl<T: Redactable> fmt::Display for Adapter<'_, T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt_redacted(self.1, f)
}
}
format!("{}", Adapter(item, r))
}
fn format_unredacted<T: Redactable>(item: &T) -> String {
struct Adapter<'a, T>(&'a T);
impl<T: Redactable> fmt::Display for Adapter<'_, T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt_unredacted(f)
}
}
format!("{}", Adapter(item))
}
#[test]
fn masking_redactor_preserves_oui_and_zeroes_suffix_and_ssid() {
assert_eq!(MaskingRedactor.redact_mac(TEST_MAC_1), [0x00, 0x1a, 0x2b, 0, 0, 0]);
assert_eq!(MaskingRedactor.redact_ssid(b"foo"), [0; SSID_DIGEST_LEN]);
assert_eq!(
format_with_redactor(&TestMac(TEST_MAC_1), &MaskingRedactor),
"00:1a:2b:00:00:00*"
);
assert_eq!(
format_with_redactor(&TestSsid(b"foo"), &MaskingRedactor),
"<ssid-0000000000000000>*"
);
}
#[test]
fn salted_redactor_preserves_oui_and_hashes_nic() {
let r1 = SaltedRedactor::new(TEST_SALT_1);
let r2 = SaltedRedactor::new(TEST_SALT_2);
assert_eq!(r1.salt(), &TEST_SALT_1);
let hashed_1 = format_with_redactor(&TestMac(TEST_MAC_1), &r1);
assert!(hashed_1.starts_with("00:1a:2b:"));
assert!(hashed_1.ends_with(REDACTED_MARKER));
assert_eq!(hashed_1.len(), "00:1a:2b:3c:4d:5e*".len());
assert_ne!(hashed_1, "00:1a:2b:3c:4d:5e*");
// Deterministic for the same salt and MAC.
assert_eq!(hashed_1, format_with_redactor(&TestMac(TEST_MAC_1), &r1));
// Different MAC with the same OUI has the same OUI prefix but a different hashed NIC.
let hashed_2 = format_with_redactor(&TestMac(TEST_MAC_2), &r1);
assert!(hashed_2.starts_with("00:1a:2b:"));
assert_ne!(hashed_1, hashed_2);
// Different salt produces a different hashed NIC.
let hashed_diff_salt = format_with_redactor(&TestMac(TEST_MAC_1), &r2);
assert!(hashed_diff_salt.starts_with("00:1a:2b:"));
assert_ne!(hashed_1, hashed_diff_salt);
}
#[test]
fn salted_redactor_hashes_ssid_deterministically() {
let r1 = SaltedRedactor::new(TEST_SALT_1);
let r2 = SaltedRedactor::new(TEST_SALT_2);
let ssid_1 = TestSsid(b"CorpWiFi");
let ssid_2 = TestSsid(b"GuestWiFi");
let hashed_1 = format_with_redactor(&ssid_1, &r1);
assert!(hashed_1.starts_with("<ssid-"));
assert!(hashed_1.ends_with(">*"));
assert_ne!(hashed_1, format_unredacted(&ssid_1));
assert_eq!(hashed_1, format_with_redactor(&ssid_1, &r1));
assert_ne!(hashed_1, format_with_redactor(&ssid_2, &r1));
assert_ne!(hashed_1, format_with_redactor(&ssid_1, &r2));
}
// The redactor state is process-global and Rust runs tests in parallel in
// one process, so everything that touches the global lives in this single
// test. Do not add a second test that calls `set_*_redactor`.
#[test]
fn global_state_machine_transitions() {
// Uninitialized -> Masking -> Salted.
set_redactor_state(State::Uninitialized);
let mac = TestMac(TEST_MAC_1);
let ssid = TestSsid(b"CorpWiFi");
// Uninitialized -> dispatch writes the type's own unredacted format.
assert_eq!(redactor_state(), State::Uninitialized);
assert_eq!(format!("{mac}"), "00:1a:2b:3c:4d:5e");
assert_eq!(format!("{mac:?}"), "00:1a:2b:3c:4d:5e");
assert_eq!(format!("{ssid}"), "<ssid-436f727057694669>");
// Transition to MASKING.
set_masking_redactor();
assert_eq!(redactor_state(), State::Masking);
assert_eq!(format!("{mac}"), "00:1a:2b:00:00:00*");
assert_eq!(format!("{ssid}"), "<ssid-0000000000000000>*");
// Transition from MASKING to SALTED.
assert_eq!(set_salted_redactor(TEST_SALT_1), Ok(()));
let expected_mac = format_with_redactor(&mac, &SaltedRedactor::new(TEST_SALT_1));
let expected_ssid = format_with_redactor(&ssid, &SaltedRedactor::new(TEST_SALT_1));
assert_eq!(format!("{mac}"), expected_mac);
assert_eq!(format!("{ssid}"), expected_ssid);
// Once SALTED, further calls to set_salted_redactor or set_masking_redactor do not overwrite.
assert_eq!(set_salted_redactor(TEST_SALT_2), Err(SetRedactorError));
set_masking_redactor();
assert_eq!(format!("{mac}"), expected_mac);
// Direct Uninitialized -> Salted (no masking step) is also supported.
set_redactor_state(State::Uninitialized);
assert_eq!(format!("{mac}"), "00:1a:2b:3c:4d:5e");
assert_eq!(set_salted_redactor(TEST_SALT_2), Ok(()));
assert_eq!(format!("{mac}"), format_with_redactor(&mac, &SaltedRedactor::new(TEST_SALT_2)));
}
}