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/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" 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(()) diff --git a/src/telemetry.rs b/src/telemetry.rs index 36b1e724..15d0ad3f 100644 --- a/src/telemetry.rs +++ b/src/telemetry.rs @@ -1,7 +1,11 @@ use crate::config; 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"); @@ -85,6 +89,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 +98,64 @@ 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 { + 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()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions( + &salt_path, + std::fs::Permissions::from_mode(0o600), + ); + } + } + salt + }) + .clone() +} + +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 +231,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]