From 32fdbbbb6923c70d343fab14b4b0ce70424e610f Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:17:00 +0100 Subject: [PATCH 1/5] fix(security): salt device hash for telemetry --- src/telemetry.rs | 75 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/src/telemetry.rs b/src/telemetry.rs index 36b1e724..914a97c4 100644 --- a/src/telemetry.rs +++ b/src/telemetry.rs @@ -1,6 +1,7 @@ use crate::config; use crate::tracking; use sha2::{Digest, Sha256}; +use std::io::Write; use std::path::PathBuf; const TELEMETRY_URL: Option<&str> = option_env!("RTK_TELEMETRY_URL"); @@ -85,6 +86,7 @@ fn send_ping() -> Result<(), Box> { } fn generate_device_hash() -> String { + let salt = get_or_create_salt(); let hostname = hostname::get() .map(|h| h.to_string_lossy().to_string()) .unwrap_or_default(); @@ -93,12 +95,52 @@ fn generate_device_hash() -> String { .unwrap_or_default(); let mut hasher = Sha256::new(); + hasher.update(salt.as_bytes()); + hasher.update(b":"); hasher.update(hostname.as_bytes()); hasher.update(b":"); hasher.update(username.as_bytes()); format!("{:x}", hasher.finalize()) } +fn get_or_create_salt() -> String { + let salt_path = salt_file_path(); + + if let Ok(contents) = std::fs::read_to_string(&salt_path) { + let trimmed = contents.trim().to_string(); + if trimmed.len() == 64 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) { + return trimmed; + } + } + + let salt = random_salt(); + if let Some(parent) = salt_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(mut f) = std::fs::File::create(&salt_path) { + let _ = f.write_all(salt.as_bytes()); + } + salt +} + +fn random_salt() -> String { + let mut buf = [0u8; 32]; + if getrandom::fill(&mut buf).is_err() { + let fallback = format!("{:?}:{}", std::time::SystemTime::now(), std::process::id()); + let mut hasher = Sha256::new(); + hasher.update(fallback.as_bytes()); + return format!("{:x}", hasher.finalize()); + } + buf.iter().map(|b| format!("{:02x}", b)).collect() +} + +fn salt_file_path() -> PathBuf { + dirs::data_local_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join("rtk") + .join(".device_salt") +} + fn get_stats() -> (i64, Vec, Option, i64, i64) { let tracker = match tracking::Tracker::new() { Ok(t) => t, @@ -174,7 +216,38 @@ mod tests { let h1 = generate_device_hash(); let h2 = generate_device_hash(); assert_eq!(h1, h2); - assert_eq!(h1.len(), 64); // SHA-256 hex + assert_eq!(h1.len(), 64); + } + + #[test] + fn test_device_hash_is_valid_hex() { + let hash = generate_device_hash(); + assert!(hash.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_salt_is_persisted() { + let s1 = get_or_create_salt(); + let s2 = get_or_create_salt(); + assert_eq!(s1, s2); + assert_eq!(s1.len(), 64); + assert!(s1.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_random_salt_uniqueness() { + let s1 = random_salt(); + let s2 = random_salt(); + assert_ne!(s1, s2); + assert_eq!(s1.len(), 64); + assert_eq!(s2.len(), 64); + } + + #[test] + fn test_salt_file_path_is_in_rtk_dir() { + let path = salt_file_path(); + assert!(path.to_string_lossy().contains("rtk")); + assert!(path.to_string_lossy().contains(".device_salt")); } #[test] From 51f9c888b81169309df92f7fa3a6f705d44adcab Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:20:15 +0100 Subject: [PATCH 2/5] fix(security): missing toml pkg --- Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.toml b/Cargo.toml index c8bed311..3f19b7f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ tempfile = "3" sha2 = "0.10" ureq = "2" hostname = "0.4" +getrandom = "0.4" flate2 = "1.0" quick-xml = "0.37" which = "8" From 33195cc686318ddcca54edfdd1215bd9fd28f891 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:28:43 +0100 Subject: [PATCH 3/5] fix(telemetry): docs + real info from "rtk init -g" --- Cargo.lock | 1 + README.md | 4 ++-- docs/tracking.md | 4 ++-- src/init.rs | 10 ++++++++-- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index db5b9cf9..2f2b776f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -900,6 +900,7 @@ dependencies = [ "colored", "dirs", "flate2", + "getrandom 0.4.2", "hostname", "ignore", "lazy_static", diff --git a/README.md b/README.md index a91743e9..c79e00b4 100644 --- a/README.md +++ b/README.md @@ -472,10 +472,10 @@ brew uninstall rtk # If installed via Homebrew ## Privacy & Telemetry -RTK collects **anonymous, aggregate usage metrics** once per day to help prioritize development. This is standard practice for open-source CLI tools. +RTK collects **anonymous, aggregate usage metrics** once per day, **enabled by default**. This helps prioritize development. See opt-out options below. **What is collected:** -- Device hash (SHA-256 of hostname+username, not reversible) +- Device hash (salted SHA-256 — per-user random salt stored locally, not reversible) - RTK version, OS, architecture - Command count (last 24h) and top command names (e.g. "git", "cargo" — no arguments, no file paths) - Token savings percentage diff --git a/docs/tracking.md b/docs/tracking.md index a5ad23e7..82c12883 100644 --- a/docs/tracking.md +++ b/docs/tracking.md @@ -538,8 +538,8 @@ let _ = conn.execute( ## Security & Privacy -- **Local storage only**: Database never leaves the machine -- **No telemetry**: RTK does not phone home or send analytics +- **Local storage only**: Tracking database never leaves the machine +- **Telemetry enabled by default**: RTK sends a daily anonymous usage ping (version, OS, command counts, token savings). Device identity is a salted SHA-256 hash. Opt out with `RTK_TELEMETRY_DISABLED=1` or `[telemetry] enabled = false` in `~/.config/rtk/config.toml` - **User control**: Users can delete `~/.local/share/rtk/tracking.db` anytime - **90-day retention**: Old data automatically purged diff --git a/src/init.rs b/src/init.rs index 53bbe70c..bb4273ec 100644 --- a/src/init.rs +++ b/src/init.rs @@ -4,6 +4,7 @@ use std::io::Write; use std::path::{Path, PathBuf}; use tempfile::NamedTempFile; +use crate::config; use crate::integrity; // Embedded hook script (guards before set -euo pipefail) @@ -279,9 +280,14 @@ pub fn run( install_cursor_hooks(verbose)?; } - // Telemetry notice (shown once during init) println!(); - println!(" [info] Anonymous telemetry is enabled (opt-out: RTK_TELEMETRY_DISABLED=1)"); + let env_disabled = std::env::var("RTK_TELEMETRY_DISABLED").unwrap_or_default() == "1"; + let config_disabled = matches!(config::telemetry_enabled(), Some(false)); + if env_disabled || config_disabled { + println!(" [info] Anonymous telemetry is disabled"); + } else { + println!(" [info] Anonymous telemetry is enabled by default (opt-out: RTK_TELEMETRY_DISABLED=1)"); + } println!(" [info] See: https://github.com/rtk-ai/rtk#privacy--telemetry"); Ok(()) From 22dc059310b0408adedc2d1228de339e16ea6c0a Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:36:41 +0100 Subject: [PATCH 4/5] fix(telemetry): cache salt in-process --- src/telemetry.rs | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/src/telemetry.rs b/src/telemetry.rs index 914a97c4..87b9d931 100644 --- a/src/telemetry.rs +++ b/src/telemetry.rs @@ -3,6 +3,9 @@ use crate::tracking; use sha2::{Digest, Sha256}; use std::io::Write; use std::path::PathBuf; +use std::sync::OnceLock; + +static CACHED_SALT: OnceLock = OnceLock::new(); const TELEMETRY_URL: Option<&str> = option_env!("RTK_TELEMETRY_URL"); const TELEMETRY_TOKEN: Option<&str> = option_env!("RTK_TELEMETRY_TOKEN"); @@ -104,23 +107,27 @@ fn generate_device_hash() -> String { } fn get_or_create_salt() -> String { - let salt_path = salt_file_path(); - - if let Ok(contents) = std::fs::read_to_string(&salt_path) { - let trimmed = contents.trim().to_string(); - if trimmed.len() == 64 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) { - return trimmed; - } - } + CACHED_SALT + .get_or_init(|| { + let salt_path = salt_file_path(); + + if let Ok(contents) = std::fs::read_to_string(&salt_path) { + let trimmed = contents.trim().to_string(); + if trimmed.len() == 64 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) { + return trimmed; + } + } - let salt = random_salt(); - if let Some(parent) = salt_path.parent() { - let _ = std::fs::create_dir_all(parent); - } - if let Ok(mut f) = std::fs::File::create(&salt_path) { - let _ = f.write_all(salt.as_bytes()); - } - salt + let salt = random_salt(); + if let Some(parent) = salt_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(mut f) = std::fs::File::create(&salt_path) { + let _ = f.write_all(salt.as_bytes()); + } + salt + }) + .clone() } fn random_salt() -> String { From 5eae11d16410dc4ff26e97672e5367b14efaab76 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Sat, 28 Mar 2026 10:46:08 +0100 Subject: [PATCH 5/5] fix(security): set 0600 permissions on salt file --- src/telemetry.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/telemetry.rs b/src/telemetry.rs index 87b9d931..15d0ad3f 100644 --- a/src/telemetry.rs +++ b/src/telemetry.rs @@ -124,6 +124,14 @@ fn get_or_create_salt() -> String { } if let Ok(mut f) = std::fs::File::create(&salt_path) { let _ = f.write_all(salt.as_bytes()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions( + &salt_path, + std::fs::Permissions::from_mode(0o600), + ); + } } salt })