feat: Teach rust side to validate hashes when installing (#171)
This commit is contained in:
@@ -27,8 +27,6 @@ log = "0.4.14"
|
||||
once_cell = "1.17.1"
|
||||
# For reading shorebird.yaml
|
||||
serde_yaml = "0.9.19"
|
||||
# For validating hashes of downloaded patch files.
|
||||
sha2 = "0.10.6"
|
||||
# For inflating compressed patch files.
|
||||
bipatch = "1.0.0"
|
||||
# comde is a wrapper around several compression libraries.
|
||||
@@ -36,6 +34,10 @@ bipatch = "1.0.0"
|
||||
comde = {version = "0.2.3", default-features = false, features = ["zstandard"]}
|
||||
# Pipe is a simple in-memory pipe implementation, there might be a std way too?
|
||||
pipe = "0.4.0"
|
||||
# For computing hashes of patch files for validation.
|
||||
sha2 = "0.10.6"
|
||||
# For decoding the hex-encoded hashes in Patch network responses.
|
||||
hex = "0.4.3"
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
# For logging to Android logcat.
|
||||
|
||||
@@ -70,17 +70,6 @@ impl UpdaterState {
|
||||
}
|
||||
}
|
||||
|
||||
// fn compute_hash(path: &Path) -> anyhow::Result<String> {
|
||||
// use sha2::{Digest, Sha256};
|
||||
// use std::{fs, io};
|
||||
|
||||
// let mut file = fs::File::open(&path)?;
|
||||
// let mut hasher = Sha256::new();
|
||||
// let n = io::copy(&mut file, &mut hasher)?;
|
||||
// let hash = hasher.finalize();
|
||||
// Ok(format!("{:x}", hash))
|
||||
// }
|
||||
|
||||
impl UpdaterState {
|
||||
pub fn is_known_good_patch(&self, patch: &PatchInfo) -> bool {
|
||||
self.successful_patches.iter().any(|v| v == &patch.number)
|
||||
|
||||
@@ -19,7 +19,8 @@ pub struct Patch {
|
||||
/// The patch number. Starts at 1 for each new release and increases
|
||||
/// monotonically.
|
||||
pub number: usize,
|
||||
/// The hash of the final uncompressed patch file.
|
||||
/// The hex-encoded sha256 hash of the final uncompressed patch file.
|
||||
/// Legacy: originally "#" before we implemented hash checks (remove).
|
||||
pub hash: String,
|
||||
/// The URL to download the patch file from.
|
||||
pub download_url: String,
|
||||
|
||||
@@ -97,6 +97,39 @@ pub fn check_for_update() -> bool {
|
||||
return with_config(check_for_update_internal);
|
||||
}
|
||||
|
||||
fn check_hash(path: &Path, expected_string: &str) -> anyhow::Result<bool> {
|
||||
let result = hex::decode(expected_string);
|
||||
// Remove this legacy behavior.
|
||||
if result.is_err() {
|
||||
warn!("Failed to decode hash from server, allowing: {expected_string}");
|
||||
return Ok(true);
|
||||
}
|
||||
let expected = result.unwrap();
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{fs, io};
|
||||
// Based on guidance from:
|
||||
// https://github.com/RustCrypto/hashes#hashing-readable-objects
|
||||
|
||||
let mut file = fs::File::open(&path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
io::copy(&mut file, &mut hasher)?;
|
||||
// Check that the length from copy is the same as the file size?
|
||||
let hash = hasher.finalize();
|
||||
let hash_matches = hash.as_slice() == expected;
|
||||
if !hash_matches {
|
||||
warn!(
|
||||
"Hash mismatch: {:?}, expected: {}, got: {:?}",
|
||||
path,
|
||||
expected_string,
|
||||
hex::encode(hash)
|
||||
);
|
||||
} else {
|
||||
info!("Hash match: {:?}", path);
|
||||
}
|
||||
return Ok(hash_matches);
|
||||
}
|
||||
|
||||
fn update_internal(config: &ResolvedConfig) -> anyhow::Result<UpdateStatus> {
|
||||
// Load the state from disk.
|
||||
let mut state = UpdaterState::load_or_new_on_error(&config.cache_dir, &config.release_version);
|
||||
@@ -121,6 +154,10 @@ fn update_internal(config: &ResolvedConfig) -> anyhow::Result<UpdateStatus> {
|
||||
}
|
||||
|
||||
// Check the hash before moving into place.
|
||||
let hash_ok = check_hash(&download_path, &patch.hash)?;
|
||||
if !hash_ok {
|
||||
return Err(UpdateError::InvalidState("Hash mismatch".to_string()).into());
|
||||
}
|
||||
// Move/state update should be "atomic".
|
||||
// Consider supporting allowing the system to download for us (e.g. iOS).
|
||||
|
||||
@@ -129,6 +166,7 @@ fn update_internal(config: &ResolvedConfig) -> anyhow::Result<UpdateStatus> {
|
||||
number: patch.number,
|
||||
};
|
||||
state.install_patch(patch_info)?;
|
||||
info!("Patch {} successfully installed.", patch.number);
|
||||
|
||||
// Set the state to "restart required".
|
||||
return Ok(UpdateStatus::UpdateInstalled);
|
||||
@@ -317,4 +355,28 @@ mod tests {
|
||||
// ask for current patch (should get none).
|
||||
assert!(crate::active_patch().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_matches() {
|
||||
let tmp_dir = TempDir::new("example").unwrap();
|
||||
|
||||
let input_path = tmp_dir.path().join("input");
|
||||
std::fs::write(&input_path, "hello world").unwrap();
|
||||
|
||||
let expected = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
|
||||
assert!(super::check_hash(&input_path, expected).unwrap());
|
||||
|
||||
// modify hash to not match
|
||||
let expected = "a94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
|
||||
assert_eq!(super::check_hash(&input_path, expected).unwrap(), false);
|
||||
|
||||
// invalid hashes should not match either
|
||||
// Except for now they do (legacy behavior).
|
||||
let expected = "foo";
|
||||
assert_eq!(super::check_hash(&input_path, expected).unwrap(), true);
|
||||
|
||||
// Remove this case when legacy clients are gone.
|
||||
let expected = "#";
|
||||
assert!(super::check_hash(&input_path, expected).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user