use unicode_normalization::UnicodeNormalization;
use crate::error::{Error, Result};
pub const PREFIX: &str = "moonbase:fingerprint:v2";
pub const VERSION: u32 = 2;
pub const MAX_VALUE_LENGTH: usize = 128;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum DeviceIdSource {
Identity,
DeviceName,
Scoped,
}
impl DeviceIdSource {
pub fn tag(self) -> &'static str {
match self {
Self::Identity => "",
Self::DeviceName => "n",
Self::Scoped => "s",
}
}
pub fn from_tag(tag: &str) -> Option<Self> {
match tag {
"" => Some(Self::Identity),
"n" => Some(Self::DeviceName),
"s" => Some(Self::Scoped),
_ => None,
}
}
}
pub type Parameter = (String, String);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeviceIdStamp {
pub version: u32,
pub source_tag: String,
pub source: Option<DeviceIdSource>,
pub digest: String,
}
const IDENTIFYING_PARAMS: [&str; 7] = [
"ioPlatformUuid",
"machineId",
"systemUuid",
"baseboardSerialNumber",
"identifierForVendor",
"androidId",
"deviceName",
];
const NOT_PROGRAMMED_VALUES: [&str; 15] = [
"to be filled by o.e.m.",
"to be filled by oem",
"default string",
"system serial number",
"base board serial number",
"chassis serial number",
"not specified",
"not applicable",
"not available",
"none",
"unknown",
"invalid",
"n/a",
"0123456789",
"uninitialized",
];
const REJECTED_ANDROID_IDS: [&str; 1] = [
"9774d56d682e549c",
];
fn is_lowercase_hex(value: &str) -> bool {
value.bytes().all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
}
pub fn canonicalize_value(value: &str) -> String {
let mut printable: String = value.nfc().filter(|c| (' '..='~').contains(c)).collect();
printable.truncate(MAX_VALUE_LENGTH);
printable.trim_matches(' ').to_owned()
}
pub const fn platform_tag() -> &'static str {
if cfg!(target_os = "android") {
"android"
} else if cfg!(target_os = "macos") || cfg!(all(target_os = "ios", target_abi = "macabi")) {
"mac"
} else if cfg!(any(target_os = "ios", target_os = "tvos", target_os = "watchos", target_os = "visionos")) {
"ios"
} else if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "linux") {
"linux"
} else if cfg!(any(target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "dragonfly")) {
"bsd"
} else {
"unknown"
}
}
pub fn identifying_param_names() -> Vec<&'static str> {
IDENTIFYING_PARAMS.to_vec()
}
pub fn is_identifying_param(name: &str) -> bool {
IDENTIFYING_PARAMS.contains(&name)
}
pub fn is_not_programmed(canonical_value: &str) -> bool {
if canonical_value.is_empty() {
return false;
}
if canonical_value.bytes().all(|byte| byte == b'0')
|| canonical_value.bytes().all(|byte| byte == b'f' || byte == b'F')
{
return true;
}
NOT_PROGRAMMED_VALUES.iter().any(|filler| canonical_value.eq_ignore_ascii_case(filler))
}
pub fn is_valid_android_id(canonical_value: &str) -> bool {
!canonical_value.is_empty()
&& canonical_value.len() <= 16
&& is_lowercase_hex(canonical_value)
&& !is_not_programmed(canonical_value)
&& !REJECTED_ANDROID_IDS.iter().any(|rejected| canonical_value.eq_ignore_ascii_case(rejected))
}
pub fn is_usable_identity(name: &str, canonical_value: &str) -> bool {
if is_not_programmed(canonical_value) {
return false;
}
if name == "androidId" {
return is_valid_android_id(canonical_value);
}
true
}
pub fn canonicalize_params<N: AsRef<str>, V: AsRef<str>>(params: &[(N, V)]) -> Result<Vec<Parameter>> {
let mut kept: Vec<Parameter> = Vec::with_capacity(params.len());
for (name, value) in params {
let name = name.as_ref();
let value = canonicalize_value(value.as_ref());
if value.is_empty() || (is_identifying_param(name) && !is_usable_identity(name, &value)) {
continue;
}
if kept.iter().any(|(kept_name, _)| kept_name == name) {
return Err(Error::DuplicateFingerprintParameter(name.to_owned()));
}
kept.push((name.to_owned(), value));
}
Ok(kept)
}
pub fn build_fingerprint_material<N: AsRef<str>, V: AsRef<str>>(platform: &str, params: &[(N, V)]) -> Result<String> {
let kept = canonicalize_params(params)?;
if kept.is_empty() {
return Err(Error::insufficient_identity(platform));
}
let scoped_platform = platform == "ios" || platform == "android";
if scoped_platform && kept.iter().any(|(name, _)| name == "deviceName") {
return Err(Error::InsufficientDeviceIdentity {
platform: platform.to_owned(),
reason: "the host-name fallback is not available on this platform, where the host name is the same \
on every device"
.into(),
});
}
if !kept.iter().any(|(name, _)| is_identifying_param(name)) {
let names: Vec<&str> = kept.iter().map(|(name, _)| name.as_str()).collect();
return Err(Error::InsufficientDeviceIdentity {
platform: platform.to_owned(),
reason: format!(
"only model-level parameters could be read ({}), none of which identify this individual machine",
names.join(", ")
),
});
}
let mut material = String::from(PREFIX);
material.push_str("\nplatform=");
material.push_str(platform);
for (name, value) in &kept {
material.push('\n');
material.push_str(name);
material.push('=');
material.push_str(value);
}
Ok(material)
}
pub fn fingerprint_digest(material: &str) -> String {
crate::crypto::sha256_hex(material.as_bytes())
}
pub fn stamp_device_id(digest: &str, source: DeviceIdSource) -> String {
format!("mbd{VERSION}{}_{digest}", source.tag())
}
pub fn fingerprint_device_id(material: &str, source: DeviceIdSource) -> String {
stamp_device_id(&fingerprint_digest(material), source)
}
pub fn parse_device_id_stamp(device_id: &str) -> Option<DeviceIdStamp> {
let rest = device_id.strip_prefix("mbd")?;
let digit_count = rest.bytes().take_while(u8::is_ascii_digit).count();
if digit_count == 0 || digit_count > 9 {
return None;
}
let (digits, rest) = rest.split_at(digit_count);
let tag_length = rest.bytes().take_while(u8::is_ascii_lowercase).count();
let (source_tag, rest) = rest.split_at(tag_length);
let digest = rest.strip_prefix('_')?;
if digest.len() != 64 || !is_lowercase_hex(digest) {
return None;
}
Some(DeviceIdStamp {
version: digits.parse().ok()?,
source_tag: source_tag.to_owned(),
source: DeviceIdSource::from_tag(source_tag),
digest: digest.to_owned(),
})
}
pub fn normalize_platform_uuid(raw: &str) -> String {
raw.chars().filter(|c| *c != '-').map(|c| c.to_ascii_uppercase()).collect()
}
pub fn parse_ioreg_platform_uuid(ioreg_output: &str) -> String {
const KEY: &str = "\"IOPlatformUUID\"";
let is_space = |c: char| matches!(c, ' ' | '\t' | '\r' | '\n');
let Some(at) = ioreg_output.find(KEY) else {
return String::new();
};
let Some(rest) = ioreg_output[at + KEY.len()..].trim_start_matches(is_space).strip_prefix('=') else {
return String::new();
};
let Some(rest) = rest.trim_start_matches(is_space).strip_prefix('"') else {
return String::new();
};
match rest.find('"') {
Some(end) if end > 0 => normalize_platform_uuid(&rest[..end]),
_ => String::new(),
}
}
pub fn is_valid_machine_id(canonical_value: &str) -> bool {
canonical_value.len() == 32 && is_lowercase_hex(canonical_value) && !is_not_programmed(canonical_value)
}
pub fn select_machine_id<S: AsRef<str>>(sources: &[S]) -> String {
sources
.iter()
.map(|source| canonicalize_value(source.as_ref()))
.find(|candidate| is_valid_machine_id(candidate))
.unwrap_or_default()
}
struct SmbiosStructure<'a> {
kind: u8,
formatted: &'a [u8],
strings: Vec<String>,
}
fn latin1(bytes: &[u8]) -> String {
bytes.iter().map(|&byte| char::from(byte)).collect()
}
fn parse_smbios_structures(data: &[u8]) -> Vec<SmbiosStructure<'_>> {
let mut structures = Vec::new();
let mut offset = 0usize;
let size = data.len();
while offset + 4 <= size {
let kind = data[offset];
let length = usize::from(data[offset + 1]);
if length < 4 || offset + length > size {
break;
}
let mut structure = SmbiosStructure { kind, formatted: &data[offset..offset + length], strings: Vec::new() };
let mut cursor = offset + length;
if cursor + 1 < size && data[cursor] == 0 && data[cursor + 1] == 0 {
cursor += 2;
} else {
while cursor < size {
let end = data[cursor..].iter().position(|&byte| byte == 0).map_or(size, |at| cursor + at);
structure.strings.push(latin1(&data[cursor..end]));
cursor = end + 1;
if cursor < size && data[cursor] == 0 {
cursor += 1;
break;
}
}
}
structures.push(structure);
if kind == 127 {
break;
}
offset = cursor;
}
structures
}
fn resolve_smbios_string(structure: &SmbiosStructure<'_>, field_offset: usize) -> String {
let Some(&index) = structure.formatted.get(field_offset) else {
return String::new();
};
let index = usize::from(index);
if index == 0 {
return String::new();
}
structure.strings.get(index - 1).cloned().unwrap_or_default()
}
fn format_smbios_uuid(structure: &SmbiosStructure<'_>, field_offset: usize) -> String {
let Some(bytes) = structure.formatted.get(field_offset..field_offset + 16) else {
return String::new();
};
if bytes.iter().all(|&byte| byte == 0x00) || bytes.iter().all(|&byte| byte == 0xFF) {
return String::new();
}
bytes.iter().map(|byte| format!("{byte:02X}")).collect()
}
pub fn parse_smbios_params(data: &[u8]) -> Vec<Parameter> {
let mut params = Vec::new();
if data.is_empty() {
return params;
}
let structures = parse_smbios_structures(data);
if let Some(system) = structures.iter().find(|structure| structure.kind == 1) {
params.push(("systemManufacturer".into(), resolve_smbios_string(system, 0x04)));
params.push(("systemProductName".into(), resolve_smbios_string(system, 0x05)));
params.push(("systemUuid".into(), format_smbios_uuid(system, 0x08)));
}
if let Some(baseboard) = structures.iter().find(|structure| structure.kind == 2) {
params.push(("baseboardManufacturer".into(), resolve_smbios_string(baseboard, 0x04)));
params.push(("baseboardProduct".into(), resolve_smbios_string(baseboard, 0x05)));
params.push(("baseboardSerialNumber".into(), resolve_smbios_string(baseboard, 0x07)));
}
params
}