use anyhow::{anyhow, Context, Result};
use keyhog_core::{Chunk, ChunkMetadata, DetectorSpec, PatternSpec, Severity};
use keyhog_scanner::CompiledScanner;
use serde::Deserialize;
use std::path::{Path, PathBuf};
pub const REPO: &str = "santhsecurity/keyhog";
pub fn release_api_base() -> String {
std::env::var("KEYHOG_RELEASE_API_BASE")
.ok()
.map(|s| s.trim_end_matches('/').to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "https://api.github.com".to_string())
}
pub const RELEASE_PUBLIC_KEY: &str = "RWTPnJ/p6xVJ3TJIxr+ZVHMD/MTHWZhsdE38Go/oD3DYBoi4bePR55go";
pub fn verify_release_signature(data: &[u8], signature: &str) -> Result<()> {
use minisign_verify::{PublicKey, Signature};
let pk = PublicKey::from_base64(RELEASE_PUBLIC_KEY)
.map_err(|e| anyhow!("embedded release public key is invalid: {e}"))?;
let sig =
Signature::decode(signature).map_err(|e| anyhow!("release signature is malformed: {e}"))?;
pk.verify(data, &sig, false)
.map_err(|e| anyhow!("release signature verification failed: {e}"))
}
#[derive(Deserialize)]
pub struct Release {
pub tag_name: String,
#[serde(default)]
pub assets: Vec<Asset>,
}
#[derive(Deserialize)]
pub struct Asset {
pub name: String,
pub browser_download_url: String,
}
pub fn asset_name(os: &str, arch: &str, cuda: bool) -> Option<String> {
match (os, arch) {
("linux", "x86_64") => Some(if cuda {
"keyhog-linux-x86_64-cuda".into()
} else {
"keyhog-linux-x86_64".into()
}),
("macos", "aarch64") => Some("keyhog-macos-aarch64".into()),
("macos", "x86_64") => Some("keyhog-macos-x86_64".into()),
("windows", "x86_64") => Some("keyhog-windows-x86_64.exe".into()),
_ => None,
}
}
pub fn parse_semver(tag: &str) -> Option<(u64, u64, u64)> {
let t = tag.trim().trim_start_matches('v');
let mut it = t.split('.');
let major = it.next()?.parse().ok()?;
let minor = it.next()?.parse().ok()?;
let patch_field = it.next()?;
let patch_digits: String = patch_field
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
let patch = patch_digits.parse().ok()?;
Some((major, minor, patch))
}
pub fn is_newer(current: &str, latest: &str) -> bool {
match (parse_semver(current), parse_semver(latest)) {
(Some(c), Some(l)) => l > c,
_ => false,
}
}
pub fn looks_like_native_executable(bytes: &[u8]) -> bool {
if bytes.len() < 4 {
return false;
}
match std::env::consts::OS {
"linux" => bytes.starts_with(&[0x7F, b'E', b'L', b'F']),
"macos" => matches!(
bytes[..4],
[0xFE, 0xED, 0xFA, 0xCE]
| [0xCE, 0xFA, 0xED, 0xFE]
| [0xFE, 0xED, 0xFA, 0xCF]
| [0xCF, 0xFA, 0xED, 0xFE]
| [0xCA, 0xFE, 0xBA, 0xBE]
| [0xBE, 0xBA, 0xFE, 0xCA]
),
_ => true,
}
}
pub fn http_client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.user_agent(format!("keyhog/{}", env!("CARGO_PKG_VERSION")))
.build()
.context("build HTTP client")
}
pub async fn resolve_release(client: &reqwest::Client, version: Option<&str>) -> Result<Release> {
let api = release_api_base();
if let Some(tag) = version {
let url = format!("{api}/repos/{REPO}/releases/tags/{tag}");
return client
.get(&url)
.send()
.await
.context("query release tag")?
.error_for_status()
.with_context(|| format!("release tag {tag} not found"))?
.json()
.await
.context("parse release JSON");
}
let url = format!("{api}/repos/{REPO}/releases?per_page=10");
let releases: Vec<Release> = client
.get(&url)
.send()
.await
.context("query releases")?
.error_for_status()
.context("query releases (HTTP status)")?
.json()
.await
.context("parse releases JSON")?;
releases
.into_iter()
.find(|r| !r.assets.is_empty())
.ok_or_else(|| anyhow!("no recent GitHub release has any assets uploaded; pass --version"))
}
pub fn select_asset(release: &Release, want_cuda: bool) -> Result<&Asset> {
let target = asset_name(std::env::consts::OS, std::env::consts::ARCH, want_cuda).ok_or_else(
|| {
anyhow!(
"no prebuilt asset for {}-{} (supported: linux-x86_64, macos-aarch64, macos-x86_64)",
std::env::consts::OS,
std::env::consts::ARCH
)
},
)?;
let fallback = asset_name(std::env::consts::OS, std::env::consts::ARCH, false);
release
.assets
.iter()
.find(|a| a.name == target)
.or_else(|| {
fallback
.as_deref()
.and_then(|f| release.assets.iter().find(|a| a.name == f))
})
.ok_or_else(|| {
anyhow!(
"release {} has no asset named {target} (or its portable fallback)",
release.tag_name
)
})
}
pub async fn download_verified_asset(client: &reqwest::Client, asset: &Asset) -> Result<Vec<u8>> {
let bytes = client
.get(&asset.browser_download_url)
.send()
.await
.context("download asset")?
.error_for_status()
.context("download asset (HTTP status)")?
.bytes()
.await
.context("read asset body")?;
if !looks_like_native_executable(&bytes) {
return Err(anyhow!(
"downloaded asset is not a {} executable ({} bytes) - refusing to install. \
The release asset may be missing or the download was intercepted.",
std::env::consts::OS,
bytes.len()
));
}
let sig_url = format!("{}.minisig", asset.browser_download_url);
let sig_resp = client
.get(&sig_url)
.send()
.await
.context("download release signature")?;
if sig_resp.status() == reqwest::StatusCode::NOT_FOUND {
if std::env::var("KEYHOG_ALLOW_UNSIGNED_UPDATE").as_deref() == Ok("1") {
eprintln!(
"warning: release asset {} is unsigned (no .minisig) and \
KEYHOG_ALLOW_UNSIGNED_UPDATE=1 is set - installing on HTTPS-only trust.",
asset.name
);
return Ok(bytes.to_vec());
}
return Err(anyhow!(
"release asset {} has no .minisig signature - refusing to install. A missing \
signature can mean a tampered download intercepted the signature fetch. Set \
KEYHOG_ALLOW_UNSIGNED_UPDATE=1 to override for a known pre-signing release.",
asset.name
));
}
let sig_text = sig_resp
.error_for_status()
.context("download release signature (HTTP status)")?
.text()
.await
.context("read release signature body")?;
verify_release_signature(&bytes, &sig_text)
.with_context(|| format!("verifying release asset {}", asset.name))?;
Ok(bytes.to_vec())
}
pub fn current_binary() -> Result<std::path::PathBuf> {
let exe = std::env::current_exe().context("locate current executable")?;
Ok(std::fs::canonicalize(&exe).unwrap_or(exe))
}
#[cfg(unix)]
pub fn install_binary(exe: &Path, bytes: &[u8]) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let dir = exe
.parent()
.ok_or_else(|| anyhow!("current executable has no parent directory"))?;
let tmp = dir.join(format!(".keyhog-update-{}.tmp", std::process::id()));
let cleanup = |e: std::io::Error| {
let _ = std::fs::remove_file(&tmp);
e
};
std::fs::write(&tmp, bytes)
.map_err(cleanup)
.with_context(|| {
format!(
"write new binary to {} (need write permission on the install dir; \
re-run with sudo or reinstall if keyhog lives in a system path)",
dir.display()
)
})?;
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))
.map_err(cleanup)
.context("chmod the new binary")?;
std::fs::rename(&tmp, exe)
.map_err(cleanup)
.with_context(|| format!("atomically replace {}", exe.display()))?;
Ok(())
}
fn stash_path(exe: &Path) -> PathBuf {
let name = exe
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "keyhog".to_string());
let parent = exe.parent().unwrap_or_else(|| Path::new("."));
parent.join(format!(".{name}.keyhog-old-{}", std::process::id()))
}
fn write_executable(path: &Path, bytes: &[u8]) -> Result<()> {
std::fs::write(path, bytes).with_context(|| {
format!(
"write new binary to {} (the install dir must be writable; re-run with \
elevated permissions or reinstall if keyhog lives in a system path)",
path.display()
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))
.context("chmod the new binary")?;
}
Ok(())
}
pub fn replace_running_binary<F>(exe: &Path, bytes: &[u8], verify: F) -> Result<Option<PathBuf>>
where
F: FnOnce(&Path) -> bool,
{
let had_prior = exe.exists();
let stash = stash_path(exe);
if had_prior {
std::fs::rename(exe, &stash).with_context(|| {
format!(
"stash the current binary to {} before replacing it (the install dir \
must be writable so a failed update can roll back)",
stash.display()
)
})?;
}
if let Err(e) = write_executable(exe, bytes) {
if had_prior {
let _ = std::fs::rename(&stash, exe);
}
return Err(e);
}
if verify(exe) {
return Ok(had_prior.then_some(stash));
}
let _ = std::fs::remove_file(exe);
if had_prior {
std::fs::rename(&stash, exe).with_context(|| {
format!(
"ROLLBACK FAILED: the new binary failed its health check and the stashed \
working binary at {} could not be restored over {}. Restore it manually.",
stash.display(),
exe.display()
)
})?;
return Err(anyhow!(
"new binary failed its post-install health check; rolled back to the previous \
working binary. The release may be broken for this host (libc/GPU driver) - \
try `keyhog update --version <older-tag>` or report the release."
));
}
Err(anyhow!(
"installed binary failed its post-install health check and was removed (no prior \
binary to roll back to). The release may be broken for this host."
))
}
pub fn reap_stale_binaries(exe: &Path) {
let Some(parent) = exe.parent() else { return };
let name = exe
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "keyhog".to_string());
let prefix = format!(".{name}.keyhog-old-");
let Ok(entries) = std::fs::read_dir(parent) else {
return;
};
for entry in entries.flatten() {
if entry
.file_name()
.to_string_lossy()
.starts_with(prefix.as_str())
{
let _ = std::fs::remove_file(entry.path());
}
}
}
#[cfg(windows)]
pub fn install_binary(exe: &Path, bytes: &[u8]) -> Result<()> {
let _ = replace_running_binary(exe, bytes, |_| true)?;
Ok(())
}
pub fn backup_path(exe: &Path) -> PathBuf {
let name = exe
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "keyhog".to_string());
let parent = exe.parent().unwrap_or_else(|| Path::new("."));
parent.join(format!(".{name}.keyhog-bak-{}", std::process::id()))
}
pub fn verify_via_doctor(exe: &Path) -> bool {
matches!(
std::process::Command::new(exe).arg("doctor").status(),
Ok(status) if status.success()
)
}
#[cfg(unix)]
pub fn install_with_rollback<F>(exe: &Path, bytes: &[u8], verify: F) -> Result<()>
where
F: FnOnce(&Path) -> bool,
{
use std::os::unix::fs::PermissionsExt;
let had_prior = exe.exists();
let backup = backup_path(exe);
if had_prior {
std::fs::copy(exe, &backup).with_context(|| {
format!(
"back up the current binary to {} before updating (the install dir must be \
writable so a failed update can roll back; re-run with sudo or reinstall if \
keyhog lives in a system path)",
backup.display()
)
})?;
let _ = std::fs::set_permissions(&backup, std::fs::Permissions::from_mode(0o755));
}
if let Err(e) = install_binary(exe, bytes) {
if had_prior {
let _ = std::fs::remove_file(&backup);
}
return Err(e);
}
if verify(exe) {
if had_prior {
let _ = std::fs::remove_file(&backup);
}
return Ok(());
}
if had_prior {
std::fs::rename(&backup, exe).with_context(|| {
format!(
"ROLLBACK FAILED: the new binary failed its health check and the backup at {} \
could not be restored over {}. Reinstall manually from {}",
backup.display(),
exe.display(),
backup.display()
)
})?;
Err(anyhow!(
"new binary failed its post-install health check; rolled back to the previous \
working binary. The release may be broken for this host (libc/GPU driver) - \
try `keyhog update --version <older-tag>` or report the release."
))
} else {
let _ = std::fs::remove_file(exe);
Err(anyhow!(
"installed binary failed its post-install health check and was removed (no prior \
binary to roll back to). The release may be broken for this host."
))
}
}
#[cfg(windows)]
pub fn install_with_rollback<F>(exe: &Path, bytes: &[u8], verify: F) -> Result<()>
where
F: FnOnce(&Path) -> bool,
{
let stash = replace_running_binary(exe, bytes, verify)?;
if let Some(stash) = stash {
let _ = std::fs::remove_file(&stash);
}
Ok(())
}
pub fn scan_engine_self_test() -> Result<bool> {
const PLANTED: &str = "KHDOCTOR_A1b2C3d4E5f6";
let detector = DetectorSpec {
id: "kh-doctor-selftest".into(),
name: "doctor self-test".into(),
service: "doctor".into(),
severity: Severity::Low,
patterns: vec![PatternSpec {
regex: "KHDOCTOR_[A-Za-z0-9]{12}".into(),
description: None,
group: None,
client_safe: false,
}],
keywords: vec!["KHDOCTOR".into()],
..Default::default()
};
let scanner = CompiledScanner::compile(vec![detector])?;
let chunk = Chunk {
data: format!("api_secret = {PLANTED}").into(),
metadata: ChunkMetadata {
source_type: "doctor".into(),
path: Some("doctor-selftest.txt".into()),
..Default::default()
},
};
let matches = scanner.scan(&chunk);
Ok(matches.iter().any(|m| m.credential.as_ref() == PLANTED))
}