Skip to main content

pb_mapper_core/
checksum.rs

1use std::io;
2#[cfg(unix)]
3use std::os::unix::fs::PermissionsExt;
4use std::path::Path;
5use std::process::Command;
6use std::sync::LazyLock;
7use std::sync::atomic::{AtomicU32, Ordering};
8
9use parking_lot::RwLock;
10
11use base64::Engine;
12use base64::engine::general_purpose::URL_SAFE_NO_PAD;
13use rand::RngExt;
14use ring::digest::{SHA256, digest};
15
16use crate::DataLenType;
17
18pub type ChecksumType = u32;
19
20/// Environment variable used by server/client processes to carry the 32-byte header key.
21pub const ENV_MSG_HEADER_KEY: &str = "MSG_HEADER_KEY";
22/// Fixed file path used to persist a machine-derived key for operators to reuse.
23pub const MACHINE_MSG_HEADER_KEY_PATH: &str = "/var/lib/pb-mapper-server/msg_header_key";
24pub const TEMP_CREDENTIAL_PREFIX: &str = "pbmt1_";
25pub const ADMIN_KEY_LEN: usize = 32;
26
27/// Administrator keys are also stored in `MSG_HEADER_KEY`. `std::env::set_var`
28/// panics on interior NUL, so the key must be printable ASCII with no whitespace.
29pub fn is_env_safe_admin_key(bytes: &[u8]) -> bool {
30    bytes.len() == ADMIN_KEY_LEN && bytes.iter().all(|byte| byte.is_ascii_graphic())
31}
32
33pub fn env_safe_admin_key_error() -> String {
34    format!(
35        "`{ENV_MSG_HEADER_KEY}` administrator key must be 32 printable ASCII bytes without whitespace or NUL"
36    )
37}
38
39const DERIVE_MSG_HEADER_KEY_TAG: &str = "pb-mapper-msg-header-key-v1";
40const DERIVE_MSG_HEADER_KEY_CHARSET: &[u8] =
41    b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
42
43struct MsgHeaderKeyState {
44    credential: RwLock<Option<Credential>>,
45    load_error: RwLock<Option<String>>,
46    hash: AtomicU32,
47}
48
49#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50pub enum Credential {
51    Admin(AesKeyType),
52    Temporary { key_id: u64, key: AesKeyType },
53}
54
55impl Credential {
56    pub fn key_id(&self) -> u64 {
57        match self {
58            Self::Admin(_) => 0,
59            Self::Temporary { key_id, .. } => *key_id,
60        }
61    }
62
63    pub fn key(&self) -> &AesKeyType {
64        match self {
65            Self::Admin(key) | Self::Temporary { key, .. } => key,
66        }
67    }
68
69    pub fn is_admin(&self) -> bool {
70        matches!(self, Self::Admin(_))
71    }
72}
73
74fn key_len_error(input: &str) -> String {
75    format!(
76        "`{ENV_MSG_HEADER_KEY}` administrator key must be exactly 32 bytes; received {} bytes",
77        input.len()
78    )
79}
80
81fn load_credential_from_env() -> Result<Option<Credential>, String> {
82    let Some(raw) = std::env::var_os(ENV_MSG_HEADER_KEY) else {
83        return Ok(None);
84    };
85    let raw = raw
86        .into_string()
87        .map_err(|_| format!("`{ENV_MSG_HEADER_KEY}` must contain valid UTF-8 credential text"))?;
88    parse_credential(raw.trim()).map(Some)
89}
90
91fn update_runtime_credential(credential: Option<Credential>) {
92    let hash = credential
93        .as_ref()
94        .map(|credential| gen_checksum_by_key(credential.key()))
95        .unwrap_or_default();
96    let mut guard = MSG_HEADER_KEY_STATE.credential.write();
97    *guard = credential;
98    *MSG_HEADER_KEY_STATE.load_error.write() = None;
99    MSG_HEADER_KEY_STATE.hash.store(hash, Ordering::Release);
100}
101
102/// Runtime key material and checksum hash.
103///
104/// This state is mutable so FFI/UI can update `MSG_HEADER_KEY` at runtime
105/// without restarting the process.
106static MSG_HEADER_KEY_STATE: LazyLock<MsgHeaderKeyState> = LazyLock::new(|| {
107    let (credential, load_error) = match load_credential_from_env() {
108        Ok(credential) => (credential, None),
109        Err(error) => {
110            tracing::error!(reason = "credential_invalid", %error, "invalid MSG_HEADER_KEY");
111            (None, Some(error))
112        }
113    };
114    let hash = credential
115        .as_ref()
116        .map(|credential| gen_checksum_by_key(credential.key()))
117        .unwrap_or_default();
118    MsgHeaderKeyState {
119        credential: RwLock::new(credential),
120        load_error: RwLock::new(load_error),
121        hash: AtomicU32::new(hash),
122    }
123});
124
125/// Return the configured process credential, failing closed when none exists.
126pub fn get_process_credential() -> Result<Credential, String> {
127    if let Some(error) = MSG_HEADER_KEY_STATE.load_error.read().clone() {
128        return Err(error);
129    }
130    MSG_HEADER_KEY_STATE.credential.read().ok_or_else(|| {
131        format!("`{ENV_MSG_HEADER_KEY}` is required; no insecure default credential is available")
132    })
133}
134
135/// Get current message header key bytes.
136pub fn get_msg_header_key() -> Result<Vec<u8>, String> {
137    get_process_credential().map(|credential| credential.key().to_vec())
138}
139
140/// Set process `MSG_HEADER_KEY` and update runtime checksum/key state.
141///
142/// - `Some(non-empty)` => validate an admin or temporary credential and apply it immediately.
143/// - `None` or empty => remove the credential. Subsequent network operations fail closed.
144pub fn set_process_msg_header_key(msg_header_key: Option<&str>) -> Result<(), String> {
145    let normalized = msg_header_key.map(str::trim).unwrap_or("");
146    if normalized.is_empty() {
147        // SAFETY: edition 2024 makes these unsafe because the environment is
148        // process-global and another thread reading it concurrently is a data
149        // race. Here the environment is only a mirror for child processes and
150        // for the initial read at startup; the credential every operation
151        // actually consults is `MSG_HEADER_KEY_STATE`, behind an `RwLock`, and
152        // it is updated immediately below.
153        unsafe { std::env::remove_var(ENV_MSG_HEADER_KEY) };
154        update_runtime_credential(None);
155        return Ok(());
156    }
157
158    let credential = parse_credential(normalized)?;
159
160    // SAFETY: as above.
161    unsafe { std::env::set_var(ENV_MSG_HEADER_KEY, normalized) };
162    update_runtime_credential(Some(credential));
163    Ok(())
164}
165
166pub fn parse_credential(raw: &str) -> Result<Credential, String> {
167    if let Some(encoded) = raw.strip_prefix(TEMP_CREDENTIAL_PREFIX) {
168        let payload = URL_SAFE_NO_PAD
169            .decode(encoded)
170            .map_err(|_| "temporary credential is not valid base64url".to_string())?;
171        if payload.len() != 45 {
172            return Err(format!(
173                "temporary credential payload must be 45 bytes, got {}",
174                payload.len()
175            ));
176        }
177        if payload[0] != 1 {
178            return Err(format!(
179                "unsupported temporary credential version {}",
180                payload[0]
181            ));
182        }
183        let expected = digest(&SHA256, &payload[..41]);
184        if expected.as_ref()[..4] != payload[41..45] {
185            return Err("temporary credential checksum mismatch".to_string());
186        }
187        // The 45-byte check above already guarantees both widths, so neither arm
188        // is reachable — but this returns `Result` anyway, so saying so costs a
189        // line and removes a panic from a path that parses network input.
190        let key_id = u64::from_be_bytes(
191            payload[1..9]
192                .try_into()
193                .map_err(|_| "temporary credential key id is malformed".to_string())?,
194        );
195        if key_id == 0 {
196            return Err("temporary credential key id must not be zero".to_string());
197        }
198        let key = payload[9..41]
199            .try_into()
200            .map_err(|_| "temporary credential key is malformed".to_string())?;
201        return Ok(Credential::Temporary { key_id, key });
202    }
203
204    let bytes = raw.as_bytes();
205    if bytes.len() != ADMIN_KEY_LEN {
206        return Err(key_len_error(raw));
207    }
208    if !is_env_safe_admin_key(bytes) {
209        return Err(env_safe_admin_key_error());
210    }
211    // Unreachable after the `ADMIN_KEY_LEN` check above, for the same reason.
212    Ok(Credential::Admin(
213        bytes.try_into().map_err(|_| key_len_error(raw))?,
214    ))
215}
216
217pub fn encode_temporary_credential(key_id: u64, key: &AesKeyType) -> String {
218    let mut payload = Vec::with_capacity(45);
219    payload.push(1);
220    payload.extend_from_slice(&key_id.to_be_bytes());
221    payload.extend_from_slice(key);
222    let checksum = digest(&SHA256, &payload);
223    payload.extend_from_slice(&checksum.as_ref()[..4]);
224    format!(
225        "{TEMP_CREDENTIAL_PREFIX}{}",
226        URL_SAFE_NO_PAD.encode(payload)
227    )
228}
229
230/// Derive a stable machine-specific `MSG_HEADER_KEY` and persist it.
231///
232/// The derivation seed is built from normalized hostname + normalized MAC list,
233/// then hashed with SHA-256.
234///
235/// Why SHA-256:
236/// - it is deterministic for the same input;
237/// - it returns exactly 32 bytes, which naturally matches the required key length.
238///
239/// The final key is represented with alphanumeric ASCII characters and written to
240/// [`MACHINE_MSG_HEADER_KEY_PATH`], and also injected into `MSG_HEADER_KEY` env
241/// for the current process.
242pub fn setup_machine_msg_header_key() -> io::Result<String> {
243    let hostname = get_machine_hostname()?;
244    let mac_addresses = get_machine_mac_addresses()?;
245    let key = derive_msg_header_key(&hostname, &mac_addresses);
246    set_process_msg_header_key(Some(&key))
247        .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
248    write_machine_msg_header_key(&key)?;
249    Ok(key)
250}
251
252fn get_machine_hostname() -> io::Result<String> {
253    if let Some(hostname) = normalize_non_empty(std::env::var("HOSTNAME").ok().as_deref()) {
254        return Ok(hostname);
255    }
256
257    if let Ok(content) = std::fs::read_to_string("/etc/hostname")
258        && let Some(hostname) = normalize_non_empty(Some(content.as_str()))
259    {
260        return Ok(hostname);
261    }
262
263    if let Ok(output) = Command::new("hostname").output()
264        && output.status.success()
265    {
266        let stdout = String::from_utf8_lossy(&output.stdout);
267        if let Some(hostname) = normalize_non_empty(Some(stdout.as_ref())) {
268            return Ok(hostname);
269        }
270    }
271
272    Err(io::Error::new(
273        io::ErrorKind::NotFound,
274        "failed to get hostname from HOSTNAME env, /etc/hostname or hostname command",
275    ))
276}
277
278fn normalize_non_empty(input: Option<&str>) -> Option<String> {
279    input.map(str::trim).and_then(|value| {
280        if value.is_empty() {
281            None
282        } else {
283            Some(value.to_ascii_lowercase())
284        }
285    })
286}
287
288fn get_machine_mac_addresses() -> io::Result<Vec<String>> {
289    if let Ok(mac_addresses) = get_machine_mac_addresses_from_sysfs()
290        && !mac_addresses.is_empty()
291    {
292        return Ok(mac_addresses);
293    }
294
295    if let Ok(mac_addresses) = get_machine_mac_addresses_from_ip_link()
296        && !mac_addresses.is_empty()
297    {
298        return Ok(mac_addresses);
299    }
300
301    if let Ok(mac_addresses) = get_machine_mac_addresses_from_ifconfig()
302        && !mac_addresses.is_empty()
303    {
304        return Ok(mac_addresses);
305    }
306
307    Err(io::Error::new(
308        io::ErrorKind::NotFound,
309        "no valid MAC address found from /sys/class/net, `ip link` or `ifconfig`",
310    ))
311}
312
313fn get_machine_mac_addresses_from_sysfs() -> io::Result<Vec<String>> {
314    let mut mac_addresses = Vec::new();
315    for entry in std::fs::read_dir("/sys/class/net")? {
316        let entry = entry?;
317        let interface = match entry.file_name().into_string() {
318            Ok(name) => name,
319            Err(_) => continue,
320        };
321        if interface == "lo" {
322            continue;
323        }
324        let address_path = entry.path().join("address");
325        let mac = match std::fs::read_to_string(address_path) {
326            Ok(mac) => match normalize_mac_address(&mac) {
327                Some(mac) => mac,
328                None => continue,
329            },
330            Err(_) => continue,
331        };
332        mac_addresses.push(format!("{interface}:{mac}"));
333    }
334    normalize_and_validate_mac_entries(&mut mac_addresses);
335    Ok(mac_addresses)
336}
337
338fn get_machine_mac_addresses_from_ip_link() -> io::Result<Vec<String>> {
339    let output = Command::new("ip").arg("link").output()?;
340    if !output.status.success() {
341        return Err(io::Error::other("`ip link` returned non-zero status"));
342    }
343    let mut mac_addresses = Vec::new();
344    let mut current_interface: Option<String> = None;
345    for line in String::from_utf8_lossy(&output.stdout).lines() {
346        if !line.starts_with(' ') {
347            current_interface = parse_interface_name_from_ip_link(line);
348            continue;
349        }
350        let line = line.trim_start();
351        if !line.starts_with("link/ether ") {
352            continue;
353        }
354        let Some(interface) = current_interface.as_ref() else {
355            continue;
356        };
357        let Some(raw_mac) = line.split_whitespace().nth(1) else {
358            continue;
359        };
360        let Some(mac) = normalize_mac_address(raw_mac) else {
361            continue;
362        };
363        mac_addresses.push(format!("{interface}:{mac}"));
364    }
365    normalize_and_validate_mac_entries(&mut mac_addresses);
366    Ok(mac_addresses)
367}
368
369fn get_machine_mac_addresses_from_ifconfig() -> io::Result<Vec<String>> {
370    let output = Command::new("ifconfig").output()?;
371    if !output.status.success() {
372        return Err(io::Error::other("`ifconfig` returned non-zero status"));
373    }
374    let mut mac_addresses = Vec::new();
375    let mut current_interface: Option<String> = None;
376    for line in String::from_utf8_lossy(&output.stdout).lines() {
377        if !line.starts_with('\t') && !line.starts_with(' ') {
378            current_interface = parse_interface_name_from_ifconfig(line);
379            continue;
380        }
381        let line = line.trim_start();
382        if !line.starts_with("ether ") {
383            continue;
384        }
385        let Some(interface) = current_interface.as_ref() else {
386            continue;
387        };
388        let Some(raw_mac) = line.split_whitespace().nth(1) else {
389            continue;
390        };
391        let Some(mac) = normalize_mac_address(raw_mac) else {
392            continue;
393        };
394        mac_addresses.push(format!("{interface}:{mac}"));
395    }
396    normalize_and_validate_mac_entries(&mut mac_addresses);
397    Ok(mac_addresses)
398}
399
400fn normalize_and_validate_mac_entries(mac_addresses: &mut Vec<String>) {
401    mac_addresses.sort();
402    mac_addresses.dedup();
403}
404
405fn parse_interface_name_from_ip_link(line: &str) -> Option<String> {
406    let mut parts = line.splitn(3, ':');
407    let _ = parts.next()?;
408    let name = parts.next()?.trim();
409    let name = name.split('@').next()?.trim();
410    if name.is_empty() || name == "lo" {
411        return None;
412    }
413    Some(name.to_string())
414}
415
416fn parse_interface_name_from_ifconfig(line: &str) -> Option<String> {
417    let name = line.split(':').next()?.trim();
418    if name.is_empty() || name == "lo" || name == "lo0" {
419        return None;
420    }
421    Some(name.to_string())
422}
423
424fn normalize_mac_address(mac: &str) -> Option<String> {
425    let mac = mac.trim().to_ascii_lowercase();
426    if mac.len() != 17 || mac == "00:00:00:00:00:00" {
427        return None;
428    }
429    for (index, ch) in mac.char_indices() {
430        if [2usize, 5, 8, 11, 14].contains(&index) {
431            if ch != ':' {
432                return None;
433            }
434        } else if !ch.is_ascii_hexdigit() {
435            return None;
436        }
437    }
438    Some(mac)
439}
440
441fn derive_msg_header_key(hostname: &str, mac_addresses: &[String]) -> String {
442    let mut normalized_mac_addresses = mac_addresses
443        .iter()
444        .map(|address| address.trim().to_ascii_lowercase())
445        .collect::<Vec<_>>();
446    normalized_mac_addresses.sort();
447    normalized_mac_addresses.dedup();
448
449    let seed = format!(
450        "{DERIVE_MSG_HEADER_KEY_TAG}|{}|{}",
451        hostname.trim().to_ascii_lowercase(),
452        normalized_mac_addresses.join("|")
453    );
454
455    // SHA-256 digest is always 32 bytes, so the downstream key length is fixed at 32.
456    let digest = digest(&SHA256, seed.as_bytes());
457
458    // Map each digest byte into an alphanumeric character to keep the key
459    // readable and shell-friendly when users copy it between server/client tools.
460    digest
461        .as_ref()
462        .iter()
463        .map(|byte| {
464            DERIVE_MSG_HEADER_KEY_CHARSET[(*byte as usize) % DERIVE_MSG_HEADER_KEY_CHARSET.len()]
465                as char
466        })
467        .collect()
468}
469
470fn write_machine_msg_header_key(key: &str) -> io::Result<()> {
471    let path = Path::new(MACHINE_MSG_HEADER_KEY_PATH);
472    if let Some(parent) = path.parent() {
473        std::fs::create_dir_all(parent).map_err(|error| {
474            io::Error::new(
475                error.kind(),
476                format!("failed to create directory `{}`: {error}", parent.display()),
477            )
478        })?;
479    }
480    std::fs::write(path, format!("{key}\n")).map_err(|error| {
481        io::Error::new(
482            error.kind(),
483            format!("failed to write key file `{}`: {error}", path.display()),
484        )
485    })?;
486    #[cfg(unix)]
487    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
488    Ok(())
489}
490
491fn gen_checksum_by_key(key: &[u8]) -> ChecksumType {
492    key.iter().fold(0u32, |hash, &byte| {
493        hash.wrapping_mul(31).wrapping_add(byte as u32)
494    })
495}
496
497#[inline]
498/// Compute frame checksum from payload length and an explicit header key.
499pub fn get_checksum_for_key(datalen: DataLenType, key: &[u8]) -> ChecksumType {
500    datalen ^ gen_checksum_by_key(key)
501}
502
503/// `true` when the process credential can key a length checksum.
504pub fn process_checksum_is_ready() -> bool {
505    get_process_credential().is_ok()
506}
507
508#[inline]
509/// Compute frame checksum from payload length and the current header key hash.
510pub fn get_checksum(datalen: DataLenType) -> ChecksumType {
511    datalen ^ MSG_HEADER_KEY_STATE.hash.load(Ordering::Acquire)
512}
513
514#[inline]
515/// Validate a frame checksum against an explicit header key.
516pub fn valid_checksum_for_key(datalen: DataLenType, checksum: ChecksumType, key: &[u8]) -> bool {
517    checksum == get_checksum_for_key(datalen, key)
518}
519
520#[inline]
521/// Validate frame checksum generated by [`get_checksum`].
522///
523/// Missing or invalid process credentials fail closed instead of accepting
524/// an unkeyed `datalen` checksum (`hash == 0`).
525pub fn valid_checksum(datalen: DataLenType, checksum: ChecksumType) -> bool {
526    process_checksum_is_ready()
527        && datalen == (checksum ^ MSG_HEADER_KEY_STATE.hash.load(Ordering::Acquire))
528}
529
530pub type AesKeyType = [u8; 32];
531
532/// Generate a random printable 32-byte key.
533///
534/// This helper is used when a transient key is preferred over deterministic
535/// machine-derived key material.
536pub fn gen_random_key() -> [u8; 32] {
537    const CHARSET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
538
539    let mut rng = rand::rng();
540    let mut random_key: AesKeyType = [0; 32];
541    (0..32).for_each(|i| {
542        let idx = rng.random_range(0..CHARSET.len());
543        random_key[i] = CHARSET[idx];
544    });
545
546    random_key
547}
548
549// This was missing its `#[cfg(test)]`, so it compiled into release builds.
550#[cfg(test)]
551mod tests {
552    #[test]
553    fn test_random_checksum() {
554        use super::*;
555        println!(
556            "{}",
557            gen_checksum_by_key(b"0123456789abcdefghijklmnopqrstuv")
558        );
559    }
560
561    #[test]
562    fn checksum_for_an_explicit_key_is_independent_of_process_state() {
563        use super::*;
564        let key = b"0123456789abcdefghijklmnopqrstuv";
565        let datalen = 32;
566        let checksum = get_checksum_for_key(datalen, key);
567        assert!(valid_checksum_for_key(datalen, checksum, key));
568        assert!(!valid_checksum_for_key(
569            datalen,
570            checksum,
571            b"abcdefghijklmnopqrstuvwxyz012345"
572        ));
573    }
574
575    #[test]
576    fn env_safe_admin_key_rejects_nul_and_accepts_printable_ascii() {
577        use super::*;
578        assert!(is_env_safe_admin_key(b"0123456789abcdefghijklmnopqrstuv"));
579        let mut with_nul = *b"0123456789abcdefghijklmnopqrstuv";
580        with_nul[8] = 0;
581        assert!(!is_env_safe_admin_key(&with_nul));
582        assert!(!is_env_safe_admin_key(b"short"));
583    }
584
585    #[tokio::test]
586    async fn clearing_the_process_credential_fails_closed_for_checksums() {
587        use super::*;
588        use crate::test_support::PROCESS_CREDENTIAL_TEST_LOCK;
589
590        let _guard = PROCESS_CREDENTIAL_TEST_LOCK.lock().await;
591        set_process_msg_header_key(Some("0123456789abcdefghijklmnopqrstuv")).unwrap();
592        let checksum = get_checksum(32);
593        assert!(valid_checksum(32, checksum));
594        set_process_msg_header_key(None).unwrap();
595        assert!(!valid_checksum(32, checksum));
596        assert!(!valid_checksum(32, 32));
597        assert!(get_process_credential().is_err());
598    }
599
600    #[test]
601    fn administrator_credentials_reject_nul_and_whitespace() {
602        use super::*;
603        let mut with_nul = *b"0123456789abcdefghijklmnopqrstuv";
604        with_nul[8] = 0;
605        assert!(parse_credential(std::str::from_utf8(&with_nul).unwrap()).is_err());
606        assert!(parse_credential("0123456789abcdefghijklmnopq rstuv").is_err());
607    }
608
609    #[test]
610    fn test_derive_msg_header_key_is_stable() {
611        use super::*;
612        let mac_addresses = vec![
613            "eth0:52:54:00:12:34:56".to_string(),
614            "ens3:02:42:ac:11:00:02".to_string(),
615        ];
616        let key1 = derive_msg_header_key("DemoHost", &mac_addresses);
617        let key2 = derive_msg_header_key("demohost", &mac_addresses);
618        assert_eq!(key1, key2);
619        assert_eq!(key1.len(), 32);
620        assert!(key1.chars().all(|ch| ch.is_ascii_alphanumeric()));
621    }
622
623    #[test]
624    fn temporary_credential_round_trip_and_checksum() {
625        use super::*;
626
627        let key = [7_u8; 32];
628        let encoded = encode_temporary_credential(0x0000_0007_0000_002a, &key);
629        assert_eq!(
630            parse_credential(&encoded).unwrap(),
631            Credential::Temporary {
632                key_id: 0x0000_0007_0000_002a,
633                key
634            }
635        );
636
637        let mut corrupted = encoded.into_bytes();
638        let last = corrupted.last_mut().unwrap();
639        *last = if *last == b'A' { b'B' } else { b'A' };
640        assert!(parse_credential(std::str::from_utf8(&corrupted).unwrap()).is_err());
641    }
642}