1111
1212import json
1313import os
14+ import secrets
15+ import warnings
1416from dataclasses import dataclass , field
1517from pathlib import Path
1618from typing import Any , Optional
2729 "performance_tracking" : True ,
2830 "feature_usage" : True ,
2931 "send_default_pii" : False ,
32+ "anon_salt" : None ,
3033}
3134
3235# Config file location
3336CONFIG_DIR = Path .home () / ".config" / "openadapt"
3437CONFIG_FILE = CONFIG_DIR / "telemetry.json"
38+ _INVALID_ANON_SALT_WARNED = False
3539
3640
3741@dataclass
@@ -48,6 +52,7 @@ class TelemetryConfig:
4852 performance_tracking : bool = True
4953 feature_usage : bool = True
5054 send_default_pii : bool = False
55+ anon_salt : Optional [str ] = None
5156
5257 _loaded : bool = field (default = False , repr = False )
5358
@@ -73,7 +78,8 @@ def _load_config_file() -> dict[str, Any]:
7378
7479 try :
7580 with open (CONFIG_FILE ) as f :
76- return json .load (f )
81+ data = json .load (f )
82+ return data if isinstance (data , dict ) else {}
7783 except (json .JSONDecodeError , OSError ):
7884 return {}
7985
@@ -122,9 +128,72 @@ def _get_env_config() -> dict[str, Any]:
122128 except ValueError :
123129 pass
124130
131+ # Optional override for deterministic anonymization in controlled environments.
132+ anon_salt = os .getenv ("OPENADAPT_TELEMETRY_ANON_SALT" )
133+ if anon_salt :
134+ if _is_valid_anon_salt (anon_salt ):
135+ config ["anon_salt" ] = anon_salt .strip ()
136+ else :
137+ _warn_invalid_anon_salt_once ()
138+
125139 return config
126140
127141
142+ def _is_valid_anon_salt (value : Any ) -> bool :
143+ """Check whether a salt value is valid for HMAC anonymization."""
144+ return isinstance (value , str ) and len (value .strip ()) >= 32
145+
146+
147+ def _warn_invalid_anon_salt_once () -> None :
148+ """Warn once per process when OPENADAPT_TELEMETRY_ANON_SALT is invalid."""
149+ global _INVALID_ANON_SALT_WARNED
150+ if _INVALID_ANON_SALT_WARNED :
151+ return
152+ warnings .warn (
153+ "Ignoring invalid OPENADAPT_TELEMETRY_ANON_SALT; must be >= 32 chars." ,
154+ stacklevel = 2 ,
155+ )
156+ _INVALID_ANON_SALT_WARNED = True
157+
158+
159+ def _generate_anon_salt () -> str :
160+ """Generate a high-entropy random salt."""
161+ return secrets .token_hex (32 )
162+
163+
164+ def get_or_create_anon_salt () -> str :
165+ """Get anonymization salt from env/config, creating one if missing.
166+
167+ Priority:
168+ 1. OPENADAPT_TELEMETRY_ANON_SALT (if valid)
169+ 2. telemetry config file `anon_salt` (if valid)
170+ 3. generated and persisted random salt
171+ """
172+ env_salt = os .getenv ("OPENADAPT_TELEMETRY_ANON_SALT" )
173+ if env_salt :
174+ if _is_valid_anon_salt (env_salt ):
175+ return env_salt .strip ()
176+ _warn_invalid_anon_salt_once ()
177+
178+ config_data = _load_config_file ()
179+ file_salt = config_data .get ("anon_salt" )
180+ if _is_valid_anon_salt (file_salt ):
181+ return str (file_salt ).strip ()
182+
183+ generated = _generate_anon_salt ()
184+ config_data ["anon_salt" ] = generated
185+ try :
186+ CONFIG_DIR .mkdir (parents = True , exist_ok = True )
187+ with open (CONFIG_FILE , "w" ) as f :
188+ json .dump (config_data , f , indent = 2 )
189+ except OSError :
190+ warnings .warn (
191+ "Failed to persist telemetry anonymization salt; using ephemeral salt for this process." ,
192+ stacklevel = 2 ,
193+ )
194+ return generated
195+
196+
128197def load_config () -> TelemetryConfig :
129198 """Load telemetry configuration from all sources.
130199
@@ -148,7 +217,7 @@ def load_config() -> TelemetryConfig:
148217 merged .update (env_config )
149218
150219 # Remove None values for fields that should use defaults
151- config_dict = {k : v for k , v in merged .items () if v is not None or k == "dsn" }
220+ config_dict = {k : v for k , v in merged .items () if v is not None or k in { "dsn" , "anon_salt" } }
152221
153222 return TelemetryConfig (** config_dict , _loaded = True )
154223
@@ -172,6 +241,7 @@ def save_config(config: TelemetryConfig) -> None:
172241 "performance_tracking" : config .performance_tracking ,
173242 "feature_usage" : config .feature_usage ,
174243 "send_default_pii" : config .send_default_pii ,
244+ "anon_salt" : config .anon_salt ,
175245 }
176246
177247 with open (CONFIG_FILE , "w" ) as f :
0 commit comments