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
20pub const ENV_MSG_HEADER_KEY: &str = "MSG_HEADER_KEY";
22pub 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
27pub 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
102static 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
125pub 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
135pub fn get_msg_header_key() -> Result<Vec<u8>, String> {
137 get_process_credential().map(|credential| credential.key().to_vec())
138}
139
140pub 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 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 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 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 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
230pub 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 let digest = digest(&SHA256, seed.as_bytes());
457
458 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]
498pub fn get_checksum_for_key(datalen: DataLenType, key: &[u8]) -> ChecksumType {
500 datalen ^ gen_checksum_by_key(key)
501}
502
503pub fn process_checksum_is_ready() -> bool {
505 get_process_credential().is_ok()
506}
507
508#[inline]
509pub fn get_checksum(datalen: DataLenType) -> ChecksumType {
511 datalen ^ MSG_HEADER_KEY_STATE.hash.load(Ordering::Acquire)
512}
513
514#[inline]
515pub fn valid_checksum_for_key(datalen: DataLenType, checksum: ChecksumType, key: &[u8]) -> bool {
517 checksum == get_checksum_for_key(datalen, key)
518}
519
520#[inline]
521pub 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
532pub 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#[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}