feat(bootstrap): implement APK signature validation and targeted binary extraction

This commit is contained in:
katR 2026-09-29 08:02:43 +05:30
parent f8343a1623
commit 44a858ace2
3 changed files with 120 additions and 0 deletions

View file

@ -0,0 +1,104 @@
// SPDX-License-Identifier: GPL-3.0-or-later
use crate::error::MolotovError;
use sha2::{Digest, Sha256};
use std::fs::File;
use std::io::Read;
use std::path::{Path, PathBuf};
use zip::ZipArchive;
pub const ROBLOX_CERT_FINGERPRINT: &str =
"1c:00:81:4e:66:35:b0:b3:f1:ff:37:37:3e:38:29:b9:28:90:54:e5:cf:51:65:70:e4:65:fc:e0:bc:95:68:5b";
pub struct ApkExtractor<'a> {
apk_path: &'a Path,
}
impl<'a> ApkExtractor<'a> {
pub fn new(apk_path: &'a Path) -> Self {
Self { apk_path }
}
pub fn validate_fingerprint(fingerprint: &str) -> Result<(), MolotovError> {
let normalized_expected = ROBLOX_CERT_FINGERPRINT.to_lowercase().replace(':', "");
let normalized_actual = fingerprint.to_lowercase().replace(':', "");
if normalized_expected == normalized_actual {
Ok(())
} else {
Err(MolotovError::SignatureMismatch {
expected: ROBLOX_CERT_FINGERPRINT.to_string(),
found: fingerprint.to_string(),
})
}
}
pub fn verify_signature(&self) -> Result<(), MolotovError> {
let file = File::open(self.apk_path)?;
let mut archive = ZipArchive::new(file)
.map_err(|e| MolotovError::ApkMalformed(format!("Failed to open APK zip: {}", e)))?;
// Locate .RSA or .DSA certificate file in META-INF
let mut cert_data = None;
for i in 0..archive.len() {
let file = archive.by_index(i)
.map_err(|e| MolotovError::ApkMalformed(e.to_string()))?;
let name = file.name();
if name.starts_with("META-INF/") && (name.ends_with(".RSA") || name.ends_with(".DSA") || name.ends_with(".EC")) {
let mut data = Vec::new();
let mut reader = file;
reader.read_to_end(&mut data)?;
cert_data = Some(data);
break;
}
}
let data = cert_data.ok_or_else(|| {
MolotovError::ApkMalformed("No signature certificate found in META-INF/".into())
})?;
// Calculate SHA-256 fingerprint of the certificate block
let mut hasher = Sha256::new();
hasher.update(&data);
let digest = hasher.finalize();
let computed_fingerprint = digest
.iter()
.map(|b| format!("{:02x}", b))
.collect::<Vec<_>>()
.join(":");
Self::validate_fingerprint(&computed_fingerprint)
}
pub fn extract_target_binaries(&self, destination_dir: &Path) -> Result<PathBuf, MolotovError> {
let file = File::open(self.apk_path)?;
let mut archive = ZipArchive::new(file)
.map_err(|e| MolotovError::ApkMalformed(e.to_string()))?;
std::fs::create_dir_all(destination_dir)?;
let mut libroblox_path = None;
for i in 0..archive.len() {
let mut file = archive.by_index(i)
.map_err(|e| MolotovError::ApkMalformed(e.to_string()))?;
let name = file.name().to_string();
if name == "lib/x86_64/libroblox.so" {
let out_path = destination_dir.join("libroblox.so");
let mut outfile = File::create(&out_path)?;
std::io::copy(&mut file, &mut outfile)?;
libroblox_path = Some(out_path);
} else if name.starts_with("assets/") && !name.ends_with('/') {
let out_path = destination_dir.join(&name);
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut outfile = File::create(&out_path)?;
std::io::copy(&mut file, &mut outfile)?;
}
}
libroblox_path.ok_or_else(|| {
MolotovError::ApkMalformed("lib/x86_64/libroblox.so not found in APK".into())
})
}
}

View file

@ -1,3 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pub mod apk;
pub mod cdn;
pub mod error;

View file

@ -0,0 +1,15 @@
// SPDX-License-Identifier: GPL-3.0-or-later
use molotov_bootstrap::apk::{ApkExtractor, ROBLOX_CERT_FINGERPRINT};
#[test]
fn test_verify_cert_fingerprint_mismatch() {
let dummy_fingerprint = "00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff";
let res = ApkExtractor::validate_fingerprint(dummy_fingerprint);
assert!(res.is_err(), "Mismatched certificate must be rejected immediately");
}
#[test]
fn test_verify_legitimate_roblox_fingerprint() {
let res = ApkExtractor::validate_fingerprint(ROBLOX_CERT_FINGERPRINT);
assert!(res.is_ok(), "Roblox official certificate fingerprint must be accepted");
}