Skip to main content

ados_macpin/
lib.rs

1#![doc = include_str!("../README.md")]
2#![warn(missing_docs)]
3
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6
7mod atomic;
8pub mod engine;
9
10/// A USB device identity.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub struct UsbId {
13    /// USB vendor id.
14    pub vid: u16,
15    /// USB product id.
16    pub pid: u16,
17}
18
19/// A chipset-quirk entry: a USB vendor id plus an optional product id. A `None`
20/// product matches every product from that vendor (a vendor-family match).
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct MacQuirk {
23    /// USB vendor id.
24    pub vid: u16,
25    /// `Some(pid)` matches one product; `None` matches the whole vendor family.
26    pub pid: Option<u16>,
27    /// Human label for logs and the operator surface.
28    pub label: &'static str,
29}
30
31/// Chipsets that have no efuse MAC and randomize their address each driver load.
32///
33/// Keep this list small and evidence-driven: a false entry would pin an adapter
34/// that does not need it.
35///
36/// AICSemi (vid 0xa69c) USB WiFi (the AIC8800 family) is the founding case:
37/// proven on an AIC8800D80 (`a69c:8d81`) that re-randomizes its MAC every boot
38/// and churns the DHCP lease. The whole vendor is matched because the AIC8800
39/// series shares this trait across its product ids.
40pub const MAC_QUIRKS: &[MacQuirk] = &[MacQuirk {
41    vid: 0xa69c,
42    pid: None,
43    label: "AICSemi AIC8800-family USB WiFi (no efuse MAC)",
44}];
45
46/// Chipsets known to carry a stable efuse MAC. These are NEVER pinned, even
47/// before the cross-boot learner has confirmed them, so a known-good radio's
48/// hardware address is never overwritten.
49///
50/// The Realtek RTL88xx USB WiFi family (vid 0x0bda) carries a real efuse MAC.
51pub const KNOWN_STABLE_EFUSE: &[MacQuirk] = &[MacQuirk {
52    vid: 0x0bda,
53    pid: None,
54    label: "Realtek RTL88xx USB WiFi (efuse MAC)",
55}];
56
57fn table_match(table: &[MacQuirk], id: UsbId) -> Option<&'static str> {
58    table.iter().find_map(|q| {
59        if q.vid == id.vid && q.pid.map(|p| p == id.pid).unwrap_or(true) {
60            Some(q.label)
61        } else {
62            None
63        }
64    })
65}
66
67/// Return the quirk label when `id` is a known no-efuse randomizer, else `None`.
68pub fn is_quirk_randomizer(id: UsbId) -> Option<&'static str> {
69    table_match(MAC_QUIRKS, id)
70}
71
72/// True when `id` is a chipset known to carry a stable efuse MAC (never pin it).
73pub fn is_known_stable_efuse(id: UsbId) -> bool {
74    table_match(KNOWN_STABLE_EFUSE, id).is_some()
75}
76
77/// A 48-bit MAC address. Serialized as the canonical lowercase colon-hex string.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub struct MacAddr(pub [u8; 6]);
80
81impl MacAddr {
82    /// True when the unicast bit (LSB of the first octet) is clear.
83    pub fn is_unicast(&self) -> bool {
84        self.0[0] & 0x01 == 0
85    }
86
87    /// True when the locally-administered bit (second-LSB of the first octet) is
88    /// set: a self-assigned address that cannot collide with a vendor OUI.
89    pub fn is_locally_administered(&self) -> bool {
90        self.0[0] & 0x02 != 0
91    }
92
93    /// Parse a colon- or dash-separated 6-octet MAC (case-insensitive).
94    pub fn parse(s: &str) -> Option<MacAddr> {
95        let mut bytes = [0u8; 6];
96        let mut n = 0;
97        for part in s.split([':', '-']) {
98            if n >= 6 {
99                return None;
100            }
101            bytes[n] = u8::from_str_radix(part.trim(), 16).ok()?;
102            n += 1;
103        }
104        if n == 6 {
105            Some(MacAddr(bytes))
106        } else {
107            None
108        }
109    }
110}
111
112impl std::fmt::Display for MacAddr {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        let b = self.0;
115        write!(
116            f,
117            "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
118            b[0], b[1], b[2], b[3], b[4], b[5]
119        )
120    }
121}
122
123impl Serialize for MacAddr {
124    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
125        s.serialize_str(&self.to_string())
126    }
127}
128
129impl<'de> Deserialize<'de> for MacAddr {
130    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
131        let s = String::deserialize(d)?;
132        MacAddr::parse(&s).ok_or_else(|| serde::de::Error::custom("invalid MAC address"))
133    }
134}
135
136/// Derive the deterministic pinned MAC for a board.
137///
138/// The address is `02:` (locally-administered + unicast) followed by the first
139/// five bytes of `sha256(machine_id[:salt])`. Keying on the stable
140/// `/etc/machine-id` makes the value deterministic per board and collision-free
141/// across boards. `salt` is empty for the only randomizing adapter on a box
142/// (reproducing the historical value bit-for-bit); when more than one quirk
143/// adapter is present the caller passes the adapter's USB topology path so each
144/// gets a distinct address.
145///
146/// Returns `None` when `machine_id` is empty: the caller must skip pinning
147/// rather than invent a non-deterministic value (which would collide across
148/// cloned images).
149pub fn derive_pinned_mac(machine_id: &str, salt: &str) -> Option<MacAddr> {
150    if machine_id.trim().is_empty() {
151        return None;
152    }
153    let mut hasher = Sha256::new();
154    if salt.is_empty() {
155        hasher.update(machine_id.as_bytes());
156    } else {
157        hasher.update(machine_id.as_bytes());
158        hasher.update(b":");
159        hasher.update(salt.as_bytes());
160    }
161    let digest = hasher.finalize();
162    Some(MacAddr([
163        0x02, digest[0], digest[1], digest[2], digest[3], digest[4],
164    ]))
165}
166
167/// The verdict for one network adapter, as written to the state file.
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
169#[serde(rename_all = "snake_case")]
170pub enum AdapterState {
171    /// A stable efuse MAC, left untouched.
172    Stable,
173    /// A randomizer we have pinned; the value lands on the next boot.
174    Pinned,
175    /// The cross-boot learner suspects randomization but is not certain; the
176    /// operator confirms before a pin is written.
177    Candidate,
178    /// A randomizer we cannot pin yet (e.g. no machine-id, no systemd-udev
179    /// link mechanism).
180    Deferred,
181    /// Pinning is disabled by config.
182    Disabled,
183}
184
185/// How an adapter's randomizer verdict was reached.
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
187#[serde(rename_all = "snake_case")]
188pub enum AdapterSource {
189    /// Matched the static quirk table.
190    Quirk,
191    /// Observed to change its MAC across boots.
192    Learned,
193    /// An operator-supplied explicit MAC override.
194    Override,
195}
196
197/// Per-adapter record in the state file.
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
199pub struct AdapterVerdict {
200    /// Kernel interface name at the time of the verdict (e.g. `wlan0`).
201    pub name: String,
202    /// `vvvv:pppp` USB id.
203    pub vidpid: String,
204    /// Stable USB topology path (e.g. `5-1.3`), port-stable across reboots.
205    pub usb_path: String,
206    /// The verdict.
207    pub state: AdapterState,
208    /// How the verdict was reached, when it is not `Stable`.
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub source: Option<AdapterSource>,
211    /// The pinned (or proposed) MAC.
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub pinned_mac: Option<MacAddr>,
214    /// The MAC the interface carried when it was enumerated.
215    #[serde(skip_serializing_if = "Option::is_none")]
216    pub last_seen_mac: Option<MacAddr>,
217    /// True when the pin was also applied to the live interface (opt-in).
218    #[serde(default)]
219    pub applied_live: bool,
220    /// Path of the `.link` file written for this adapter, if any.
221    #[serde(skip_serializing_if = "Option::is_none")]
222    pub link_file: Option<String>,
223    /// A short human reason when `state` is `Deferred`.
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub deferred_reason: Option<String>,
226}
227
228/// The cross-boot learner's per-device memory. Keyed by the stable device
229/// identity so a churning MAC + churning kernel name still resolves to the
230/// same record.
231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
232pub struct LearnerRecord {
233    /// `vvvv:pppp` USB id.
234    pub vidpid: String,
235    /// Stable USB topology path.
236    pub usb_path: String,
237    /// The MAC seen on the latest boot.
238    pub last_mac: MacAddr,
239    /// UNIX seconds of the first sighting.
240    pub first_seen: u64,
241    /// Boots on which the device was observed.
242    pub boot_count: u32,
243    /// Boots on which its MAC differed from the previous one.
244    pub mac_change_count: u32,
245}
246
247/// On-disk schema version of [`MacPinsState`].
248pub const MAC_PINS_STATE_VERSION: u32 = 1;
249
250/// The state-file document: per-adapter verdicts plus the learner memory. The
251/// JSON shape may be read by other programs, so extend it additively only.
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253pub struct MacPinsState {
254    /// Schema version ([`MAC_PINS_STATE_VERSION`]).
255    pub version: u32,
256    /// Verdicts from the latest reconcile.
257    #[serde(default)]
258    pub adapters: Vec<AdapterVerdict>,
259    #[serde(default)]
260    /// Learner memory, one record per device ever seen.
261    pub learner: Vec<LearnerRecord>,
262    /// UNIX seconds the state was last written.
263    #[serde(default)]
264    pub updated_at: u64,
265}
266
267impl Default for MacPinsState {
268    fn default() -> Self {
269        MacPinsState {
270            version: MAC_PINS_STATE_VERSION,
271            adapters: Vec::new(),
272            learner: Vec::new(),
273            updated_at: 0,
274        }
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn quirk_table_matches_aic_vendor_family() {
284        assert!(is_quirk_randomizer(UsbId {
285            vid: 0xa69c,
286            pid: 0x8d81
287        })
288        .is_some());
289        assert!(is_quirk_randomizer(UsbId {
290            vid: 0xa69c,
291            pid: 0x0001
292        })
293        .is_some());
294        assert!(is_quirk_randomizer(UsbId {
295            vid: 0x0bda,
296            pid: 0xa81a
297        })
298        .is_none());
299        assert!(is_quirk_randomizer(UsbId {
300            vid: 0x1234,
301            pid: 0x5678
302        })
303        .is_none());
304    }
305
306    #[test]
307    fn efuse_allowlist_protects_the_rtl_radio() {
308        assert!(is_known_stable_efuse(UsbId {
309            vid: 0x0bda,
310            pid: 0xa81a
311        }));
312        assert!(is_known_stable_efuse(UsbId {
313            vid: 0x0bda,
314            pid: 0x8812
315        }));
316        assert!(!is_known_stable_efuse(UsbId {
317            vid: 0xa69c,
318            pid: 0x8d81
319        }));
320    }
321
322    #[test]
323    fn derive_matches_the_proven_rig_value() {
324        // Parity lock: this exact machine-id produced 02:c6:75:83:1a:3e on the
325        // hardware rig (across a real power-cycle). Changing the derivation must
326        // break this test.
327        let mac = derive_pinned_mac("03851cd61fc642d781d3f93a00e624cd", "").unwrap();
328        assert_eq!(mac.to_string(), "02:c6:75:83:1a:3e");
329    }
330
331    #[test]
332    fn derived_mac_is_unicast_and_locally_administered() {
333        let mac = derive_pinned_mac("any-machine-id", "").unwrap();
334        assert!(mac.is_unicast());
335        assert!(mac.is_locally_administered());
336        assert_eq!(mac.0[0], 0x02);
337    }
338
339    #[test]
340    fn salt_distinguishes_two_adapters_on_one_box() {
341        let a = derive_pinned_mac("mid", "5-1.3").unwrap();
342        let b = derive_pinned_mac("mid", "5-1.4").unwrap();
343        assert_ne!(a, b);
344        let single = derive_pinned_mac("mid", "").unwrap();
345        assert_ne!(a, single);
346    }
347
348    #[test]
349    fn empty_machine_id_skips() {
350        assert!(derive_pinned_mac("", "").is_none());
351        assert!(derive_pinned_mac("   ", "5-1.3").is_none());
352    }
353
354    #[test]
355    fn mac_parse_round_trips() {
356        let m = MacAddr::parse("02:C6:75:83:1A:3E").unwrap();
357        assert_eq!(m.to_string(), "02:c6:75:83:1a:3e");
358        assert_eq!(MacAddr::parse("02-c6-75-83-1a-3e").unwrap(), m);
359        assert!(MacAddr::parse("not-a-mac").is_none());
360        assert!(MacAddr::parse("02:c6:75:83:1a").is_none());
361    }
362
363    #[test]
364    fn state_file_serializes_to_the_expected_shape() {
365        let st = MacPinsState {
366            version: MAC_PINS_STATE_VERSION,
367            adapters: vec![AdapterVerdict {
368                name: "wlan0".into(),
369                vidpid: "a69c:8d81".into(),
370                usb_path: "5-1.3".into(),
371                state: AdapterState::Pinned,
372                source: Some(AdapterSource::Quirk),
373                pinned_mac: MacAddr::parse("02:c6:75:83:1a:3e"),
374                last_seen_mac: MacAddr::parse("88:00:33:77:f3:bd"),
375                applied_live: false,
376                link_file: Some("/etc/systemd/network/10-ados-mac-wlan0.link".into()),
377                deferred_reason: None,
378            }],
379            learner: Vec::new(),
380            updated_at: 1_700_000_000,
381        };
382        let json = serde_json::to_string(&st).unwrap();
383        assert!(json.contains("\"state\":\"pinned\""));
384        assert!(json.contains("\"source\":\"quirk\""));
385        assert!(json.contains("\"pinned_mac\":\"02:c6:75:83:1a:3e\""));
386        let back: MacPinsState = serde_json::from_str(&json).unwrap();
387        assert_eq!(back, st);
388    }
389}