use anyhow::{anyhow, Context, Result};
use keyhog_core::{Chunk, ChunkMetadata, DetectorFile};
use keyhog_scanner::{hw_probe::ScanBackend, CompiledScanner};
use serde::Deserialize;
use std::time::Duration;
pub(crate) const REPO: &str = "santhreal/keyhog";
const RELEASE_CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
const RELEASE_REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
const MAX_RELEASE_METADATA_BYTES: usize = 8 * 1024 * 1024;
const MAX_RELEASE_ASSET_BYTES: usize = 512 * 1024 * 1024;
const MAX_RELEASE_SIGNATURE_BYTES: usize = 64 * 1024;
const MAX_RELEASE_CHECKSUM_BYTES: usize = 64 * 1024;
const MAX_RESPONSE_PREALLOC_BYTES: usize = 64 * 1024;
const RELEASE_API_BASE: &str = "https://api.github.com";
pub(crate) fn release_api_base() -> &'static str {
RELEASE_API_BASE
}
pub(crate) const RELEASE_PUBLIC_KEY: &str =
"RWTPnJ/p6xVJ3TJIxr+ZVHMD/MTHWZhsdE38Go/oD3DYBoi4bePR55go";
pub(crate) 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(Clone, Deserialize)]
pub(crate) struct Release {
pub tag_name: String,
#[serde(default)]
pub draft: bool,
#[serde(default)]
pub prerelease: bool,
#[serde(default)]
pub assets: Vec<Asset>,
}
#[derive(Clone, Deserialize)]
pub(crate) struct Asset {
pub name: String,
pub browser_download_url: String,
}
#[derive(Debug)]
pub(crate) enum ReleaseVersionError {
InvalidRequested { requested: String },
InvalidLatestResponse { returned: String },
InvalidExactResponse { requested: String, returned: String },
MismatchedExactResponse { requested: String, returned: String },
}
impl std::fmt::Display for ReleaseVersionError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidRequested { requested } => write!(
formatter,
"requested release version `{requested}` is not canonical SemVer; pass a tag such as `--version v1.2.3` (valid prereleases such as `v1.2.3-rc.1` are accepted) and retry"
),
Self::InvalidLatestResponse { returned } => write!(
formatter,
"GitHub returned malformed release tag `{returned}`; no update was selected. Retry later or pass a trusted exact tag such as `--version v1.2.3`"
),
Self::InvalidExactResponse {
requested,
returned,
} => write!(
formatter,
"requested release tag `{requested}`, but GitHub returned malformed tag `{returned}`; refusing release substitution before asset download. Verify the published tag and retry with `--version {requested}`"
),
Self::MismatchedExactResponse {
requested,
returned,
} => write!(
formatter,
"requested release tag `{requested}`, but GitHub returned `{returned}`; refusing release substitution before asset download. Verify the published tag and retry with `--version {requested}`"
),
}
}
}
impl std::error::Error for ReleaseVersionError {}
pub(crate) fn asset_name(os: &str, arch: &str) -> Option<String> {
match (os, arch) {
("linux", "x86_64") => Some("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(crate) fn parse_version(tag: &str) -> Option<semver::Version> {
let value = match tag.strip_prefix('v') {
Some(value) => value,
None => tag,
};
match semver::Version::parse(value) {
Ok(version) => Some(version),
Err(_) => None, }
}
pub(crate) fn normalize_requested_version(
requested: &str,
) -> std::result::Result<String, ReleaseVersionError> {
let parsed = parse_version(requested).ok_or_else(|| ReleaseVersionError::InvalidRequested {
requested: requested.to_owned(),
})?;
let bare = parsed.to_string();
let canonical = format!("v{bare}");
if requested != bare && requested != canonical {
return Err(ReleaseVersionError::InvalidRequested {
requested: requested.to_owned(),
});
}
Ok(canonical)
}
fn validate_latest_response_tag(returned: &str) -> std::result::Result<(), ReleaseVersionError> {
parse_version(returned)
.map(|_| ())
.ok_or_else(|| ReleaseVersionError::InvalidLatestResponse {
returned: returned.to_owned(),
})
}
fn validate_exact_response_tag(
requested: &str,
returned: &str,
) -> std::result::Result<(), ReleaseVersionError> {
let parsed =
parse_version(returned).ok_or_else(|| ReleaseVersionError::InvalidExactResponse {
requested: requested.to_owned(),
returned: returned.to_owned(),
})?;
let normalized_returned = format!("v{parsed}");
if returned != normalized_returned || returned != requested {
return Err(ReleaseVersionError::MismatchedExactResponse {
requested: requested.to_owned(),
returned: returned.to_owned(),
});
}
Ok(())
}
pub(crate) fn parse_semver(tag: &str) -> Option<(u64, u64, u64)> {
let version = parse_version(tag)?;
Some((version.major, version.minor, version.patch))
}
pub(crate) fn is_newer(current: &str, latest: &str) -> bool {
match (parse_version(current), parse_version(latest)) {
(Some(c), Some(l)) => l.cmp_precedence(&c).is_gt(),
_ => false,
}
}
pub(crate) enum ReleaseChannelState {
UpdateAvailable,
OnNewestAsset,
ChannelBehind,
}
pub(crate) fn classify_channel(current: &str, latest: &str) -> ReleaseChannelState {
if is_newer(current, latest) {
return ReleaseChannelState::UpdateAvailable;
}
if is_newer(latest, current) {
return ReleaseChannelState::ChannelBehind;
}
ReleaseChannelState::OnNewestAsset
}
pub(crate) fn looks_like_native_executable(bytes: &[u8]) -> bool {
looks_like_native_executable_for_os(bytes, std::env::consts::OS)
}
pub(crate) fn looks_like_native_executable_for_os(bytes: &[u8], os: &str) -> bool {
if bytes.len() < 4 {
return false;
}
match 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]
),
"windows" => bytes.starts_with(b"MZ"),
_ => false,
}
}
pub(crate) fn http_client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.user_agent(format!("keyhog/{}", env!("CARGO_PKG_VERSION")))
.connect_timeout(RELEASE_CONNECT_TIMEOUT)
.timeout(RELEASE_REQUEST_TIMEOUT)
.build()
.context("build HTTP client")
}
pub(crate) async fn resolve_release(
client: &reqwest::Client,
version: Option<&str>,
) -> Result<Release> {
resolve_release_at(client, version, RELEASE_API_BASE).await
}
pub(crate) async fn resolve_release_at(
client: &reqwest::Client,
version: Option<&str>,
release_api_base: &str,
) -> Result<Release> {
keyhog_profile::instrument_future(keyhog_profile::Stage::SourceAcquire, async move {
let api = release_api_base.trim().trim_end_matches('/');
if api.is_empty() {
anyhow::bail!("injected release API base is empty");
}
if let Some(requested) = version {
let tag = normalize_requested_version(requested)?;
let url = format!("{api}/repos/{REPO}/releases/tags/{tag}");
let response = client.get(&url).send().await.context("query release tag")?;
let bytes = read_limited_response(
response,
MAX_RELEASE_METADATA_BYTES,
&format!("release tag {tag}"),
)
.await?;
let release: Release = serde_json::from_slice(&bytes).context("parse release JSON")?;
if release.draft {
anyhow::bail!("release tag {tag} is still a draft and cannot be installed");
}
validate_exact_response_tag(&tag, &release.tag_name)?;
Ok(release)
} else {
let url = format!("{api}/repos/{REPO}/releases?per_page=10");
let response = client.get(&url).send().await.context("query releases")?;
let bytes = read_limited_response(response, MAX_RELEASE_METADATA_BYTES, "release list").await?;
let releases: Vec<Release> = serde_json::from_slice(&bytes).context("parse releases JSON")?;
let release = releases
.into_iter()
.find(|release| {
!release.draft && !release.prerelease && release_has_complete_host_bundle(release)
})
.ok_or_else(|| {
anyhow!(
"no recent stable GitHub release has the complete signed asset bundle for this host; pass --version to diagnose an exact tag"
)
})?;
validate_latest_response_tag(&release.tag_name)?;
Ok(release)
}
})
.await
}
fn release_has_complete_host_bundle(release: &Release) -> bool {
let Some(binary) = asset_name(std::env::consts::OS, std::env::consts::ARCH) else {
return false;
};
let required = [
binary.clone(),
format!("{binary}.sha256"),
format!("{binary}.minisig"),
format!("{binary}.gpu-literals.tar.gz"),
format!("{binary}.gpu-literals.tar.gz.sha256"),
format!("{binary}.gpu-literals.tar.gz.minisig"),
];
required
.iter()
.all(|name| find_unique_asset(release, name).is_ok())
}
fn find_unique_asset<'a>(release: &'a Release, name: &str) -> Result<&'a Asset> {
let mut matches = release.assets.iter().filter(|asset| asset.name == name);
let asset = matches
.next()
.ok_or_else(|| anyhow!("release {} has no asset named {name}", release.tag_name))?;
if matches.next().is_some() {
anyhow::bail!(
"release {} contains duplicate assets named {name}; refusing ambiguous release metadata",
release.tag_name
);
}
Ok(asset)
}
pub(crate) async fn resolve_and_download_verified_payload_at(
client: &reqwest::Client,
version: Option<&str>,
release_api_base: &str,
asset_name: &str,
) -> Result<Vec<u8>> {
let release = resolve_release_at(client, version, release_api_base).await?;
let asset = find_unique_asset(&release, asset_name)?;
keyhog_profile::instrument_future(
keyhog_profile::Stage::SourceAcquire,
download_verified_payload(
client,
&release,
asset,
MAX_RELEASE_ASSET_BYTES,
"release test payload",
),
)
.await
}
pub(crate) fn select_asset(release: &Release) -> Result<&Asset> {
let _plan_span = keyhog_profile::span(keyhog_profile::Stage::Preprocess);
let target = asset_name(std::env::consts::OS, std::env::consts::ARCH).ok_or_else(|| {
anyhow!(
"no prebuilt asset for {}-{} (supported: linux-x86_64, macos-aarch64, macos-x86_64, windows-x86_64)",
std::env::consts::OS,
std::env::consts::ARCH
)
})?;
find_unique_asset(release, &target)
}
pub(crate) fn select_gpu_literal_asset<'a>(
release: &'a Release,
binary: &Asset,
) -> Result<&'a Asset> {
find_unique_asset(release, &format!("{}.gpu-literals.tar.gz", binary.name))
}
pub(crate) async fn download_verified_asset(
client: &reqwest::Client,
release: &Release,
asset: &Asset,
) -> Result<Vec<u8>> {
let bytes = keyhog_profile::instrument_future(
keyhog_profile::Stage::SourceAcquire,
download_verified_payload(
client,
release,
asset,
MAX_RELEASE_ASSET_BYTES,
"release asset",
),
)
.await?;
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()
));
}
Ok(bytes)
}
pub(crate) async fn download_verified_gpu_literal_asset(
client: &reqwest::Client,
release: &Release,
asset: &Asset,
) -> Result<Vec<u8>> {
keyhog_profile::instrument_future(
keyhog_profile::Stage::SourceAcquire,
download_verified_payload(
client,
release,
asset,
MAX_RELEASE_ASSET_BYTES,
"GPU literal sidecar",
),
)
.await
}
async fn download_verified_payload(
client: &reqwest::Client,
release: &Release,
asset: &Asset,
max_bytes: usize,
label: &str,
) -> Result<Vec<u8>> {
let response = client
.get(&asset.browser_download_url)
.send()
.await
.with_context(|| format!("download {label} {}", asset.name))?;
let bytes = read_limited_response(response, max_bytes, label).await?;
let signature_name = format!("{}.minisig", asset.name);
let signature_asset = find_unique_asset(release, &signature_name)?;
let sig_resp = client
.get(&signature_asset.browser_download_url)
.send()
.await
.with_context(|| format!("download release signature {signature_name}"))?;
if sig_resp.status() == reqwest::StatusCode::NOT_FOUND {
return Err(anyhow!(
"release asset {} has no .minisig signature - refusing to install. A missing \
signature can mean a tampered download intercepted the signature fetch, or a \
broken release. Re-run the update against a properly signed release.",
asset.name
));
}
let sig_bytes =
read_limited_response(sig_resp, MAX_RELEASE_SIGNATURE_BYTES, "release signature").await?;
let sig_text = std::str::from_utf8(&sig_bytes).context("release signature is not UTF-8")?;
verify_release_signature(&bytes, &sig_text)
.with_context(|| format!("verifying release asset {}", asset.name))?;
let checksum_name = format!("{}.sha256", asset.name);
let checksum_asset = find_unique_asset(release, &checksum_name)?;
let checksum_response = client
.get(&checksum_asset.browser_download_url)
.send()
.await
.with_context(|| format!("download release checksum {checksum_name}"))?;
let checksum_bytes = read_limited_response(
checksum_response,
MAX_RELEASE_CHECKSUM_BYTES,
"release checksum",
)
.await?;
verify_release_checksum(&bytes, &asset.name, &checksum_bytes)?;
Ok(bytes)
}
pub(crate) fn verify_release_checksum(
data: &[u8],
asset_name: &str,
checksum_file: &[u8],
) -> Result<()> {
use sha2::{Digest as _, Sha256};
let _verify_span = keyhog_profile::span(keyhog_profile::Stage::Preprocess);
let text = std::str::from_utf8(checksum_file).context("release checksum is not UTF-8")?;
let mut fields = text.split_whitespace();
let expected = fields
.next()
.ok_or_else(|| anyhow!("release checksum for {asset_name} is empty"))?;
if expected.len() != 64 || !expected.bytes().all(|byte| byte.is_ascii_hexdigit()) {
anyhow::bail!("release checksum for {asset_name} is not a 64-digit SHA-256 digest");
}
if let Some(label) = fields.next() {
let label = label.strip_prefix('*').unwrap_or(label); if label != asset_name {
anyhow::bail!(
"release checksum labels `{label}` but payload is `{asset_name}`; refusing mismatched manifest"
);
}
}
if fields.next().is_some() {
anyhow::bail!("release checksum for {asset_name} contains unexpected trailing fields");
}
let actual = keyhog_core::hex_encode(&Sha256::digest(data));
if !actual.eq_ignore_ascii_case(expected) {
anyhow::bail!(
"release checksum mismatch for {asset_name}: expected {expected}, calculated {actual}"
);
}
Ok(())
}
async fn read_limited_response(
response: reqwest::Response,
max_bytes: usize,
label: &str,
) -> Result<Vec<u8>> {
use futures_util::StreamExt as _;
let response = response
.error_for_status()
.with_context(|| format!("{label} HTTP status"))?;
if response
.content_length()
.is_some_and(|length| length > max_bytes as u64)
{
anyhow::bail!("{label} exceeds the {max_bytes}-byte download limit");
}
let capacity = response
.content_length()
.and_then(|length| usize::try_from(length).ok()) .map_or(0, |length| length.min(MAX_RESPONSE_PREALLOC_BYTES));
let mut body = Vec::with_capacity(capacity);
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.with_context(|| format!("read {label} body"))?;
if body.len().saturating_add(chunk.len()) > max_bytes {
anyhow::bail!("{label} exceeds the {max_bytes}-byte download limit");
}
body.extend_from_slice(&chunk);
}
Ok(body)
}
pub(crate) fn scan_engine_self_test() -> Result<bool> {
let _self_test_span = keyhog_profile::span(keyhog_profile::Stage::Preprocess);
const PLANTED: &str = "KHDOCTOR_A1b2C3d4E5f6";
let detector =
toml::from_str::<DetectorFile>(include_str!("../../data/doctor-self-test-detector.toml"))
.context("bundled doctor self-test detector TOML is invalid")?
.detector;
let scanner = CompiledScanner::compile_with_gpu_policy(
vec![detector],
keyhog_scanner::GpuInitPolicy::ForceDisabled,
)?;
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_with_backend(&chunk, ScanBackend::CpuFallback)?;
Ok(matches.iter().any(|m| m.credential.as_ref() == PLANTED))
}