feat(bootstrap): implement APK signature validation and targeted binary extraction
This commit is contained in:
parent
f8343a1623
commit
44a858ace2
3 changed files with 120 additions and 0 deletions
104
crates/molotov-bootstrap/src/apk.rs
Normal file
104
crates/molotov-bootstrap/src/apk.rs
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
pub mod apk;
|
||||
pub mod cdn;
|
||||
pub mod error;
|
||||
|
|
|
|||
15
crates/molotov-bootstrap/tests/apk_test.rs
Normal file
15
crates/molotov-bootstrap/tests/apk_test.rs
Normal 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");
|
||||
}
|
||||
Reference in a new issue