icare-licensing 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,154 @@
1
+ Metadata-Version: 2.4
2
+ Name: icare-licensing
3
+ Version: 1.0.0
4
+ Summary: Secure cryptographic offline-first licensing client library for Icare licensing system
5
+ Author: Icare Infosystems
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: License :: OSI Approved :: MIT License
8
+ Classifier: Operating System :: OS Independent
9
+ Requires-Python: >=3.7
10
+ Description-Content-Type: text/markdown
11
+ License-File: license.lic
12
+ License-File: license_metadata.json
13
+ Requires-Dist: requests>=2.25.0
14
+ Requires-Dist: cryptography>=3.0.0
15
+ Dynamic: license-file
16
+
17
+ # Icare Licensing Client
18
+
19
+ `icare-liecensing-client` is an offline-first, cryptographically secure licensing client library for Python applications. It integrates seamlessly with the Icare licensing server using RSA-SHA256 signatures, device hardware bindings, and automated offline-resilient sync policies.
20
+
21
+ ---
22
+
23
+ ## Features
24
+ - **Stable Hardware Binding (HWID)**: Generates a stable machine signature.
25
+ - **Offline Cryptographic Validation**: Validates license state offline using RSA-SHA256.
26
+ - **Automated Sync Daemon**: Runs in the background and pings the verification endpoint every 24 hours.
27
+ - **3-Day Offline Grace Period**: Remains fully functional offline for up to 3 days before requiring internet.
28
+ - **Instant Revocation Purge**: Deletes local signatures and metadata immediately upon server-side license revocation or deletion to prevent offline bypass.
29
+
30
+ ---
31
+
32
+ ## 1. Installation
33
+
34
+ Install the package from your PyPI registry:
35
+ ```bash
36
+ pip install icare-licensing
37
+ ```
38
+
39
+ ---
40
+
41
+ ## 2. License Storage & File Location Policy
42
+
43
+ By default, the client library writes `license.lic` and `license_metadata.json` relative to the **Current Working Directory (CWD)** of the executing process.
44
+
45
+ ### Critical Production Guidelines
46
+ For desktop deployments or system-wide CLI tools, running relative to the CWD can lead to:
47
+ 1. **Permission Denials (`PermissionError`)**: If the app is run from a read-only directory (e.g. `Program Files` or `/usr/local/bin`).
48
+ 2. **Missing Licenses**: If the user starts the application from a different terminal directory, the client will look in the wrong path and report the license is missing.
49
+
50
+ ### Recommendation: Use Absolute Paths
51
+ Always initialize the client with an **absolute path** pointing to a user-writable application directory (such as the user's home folder or standard application data directory).
52
+
53
+ ```python
54
+ import os
55
+ from licensing_client import LicensingClient
56
+
57
+ # Determine a safe system path (e.g., ~/.config/my_app/ or %APPDATA%/my_app/)
58
+ app_folder = os.path.join(os.path.expanduser("~"), ".my_app")
59
+ os.makedirs(app_folder, exist_ok=True)
60
+
61
+ license_path = os.path.join(app_folder, "license.lic")
62
+
63
+ # Initialize client
64
+ client = LicensingClient(
65
+ server_url="https://licensing.mycompany.com",
66
+ license_file_path=license_path
67
+ )
68
+ ```
69
+ *Note: The metadata file (`license_metadata.json`) will automatically be generated in the same target folder.*
70
+
71
+ ---
72
+
73
+ ## 3. Core Developer API
74
+
75
+ ### Licensing States
76
+ The library exposes six core status constants:
77
+ * `STATUS_VALID`: License signature is cryptographically valid, matches machine HWID, is not expired, and is synced.
78
+ * `STATUS_MISSING`: No `license.lic` file is present (Device needs activation).
79
+ * `STATUS_EXPIRED`: The local or server expiration date has been exceeded.
80
+ * `STATUS_INVALID`: The license cryptographic signature is corrupt, has been modified, or has been actively revoked/deleted on the server.
81
+ * `STATUS_REQUIRES_SYNC`: The application has been running offline for more than 3 days and requires internet to refresh.
82
+ * `STATUS_UNKNOWN`: State is not yet resolved.
83
+
84
+ ---
85
+
86
+ ## 4. Integration Template
87
+
88
+ Copy this robust integration template into your project's main entry point:
89
+
90
+ ```python
91
+ import os
92
+ import sys
93
+ from licensing_client import (
94
+ LicensingClient,
95
+ STATUS_VALID,
96
+ STATUS_REQUIRES_SYNC,
97
+ STATUS_MISSING,
98
+ STATUS_INVALID,
99
+ STATUS_EXPIRED
100
+ )
101
+
102
+ def on_license_status_change(new_status):
103
+ """
104
+ Callback triggered when the license status transitions.
105
+ NOTE: If running a GUI application (e.g., Tkinter/PyQt), make sure
106
+ to route UI-altering updates safely to the main thread.
107
+ """
108
+ print(f"[Licensing] Status changed to: {new_status}")
109
+
110
+ if new_status == STATUS_VALID:
111
+ print("Premium features unlocked!")
112
+ # Proceed to launch normal application logic
113
+
114
+ elif new_status == STATUS_REQUIRES_SYNC:
115
+ print("Warning: internet connection required to verify license.")
116
+ # Optional: Show a warning banner, but keep standard features unlocked
117
+
118
+ elif new_status in (STATUS_MISSING, STATUS_INVALID, STATUS_EXPIRED):
119
+ print("Security Lock: No valid license active.")
120
+ # Action: Redirect user to your license activation overlay/screen
121
+ # Lock down core software capabilities
122
+
123
+ def main():
124
+ # 1. Setup secure storage paths
125
+ app_folder = os.path.join(os.path.expanduser("~"), ".my_app")
126
+ os.makedirs(app_folder, exist_ok=True)
127
+ license_path = os.path.join(app_folder, "license.lic")
128
+
129
+ # 2. Instantiate Client
130
+ # max_offline_days determines how long the app can run offline (default: 3 days)
131
+ client = LicensingClient(
132
+ server_url="http://localhost:8081",
133
+ license_file_path=license_path,
134
+ status_callback=on_license_status_change,
135
+ max_offline_days=3
136
+ )
137
+
138
+ # 3. Handle Initial State
139
+ # Force a check immediately on startup
140
+ client._run_check()
141
+
142
+ if client.current_status == STATUS_MISSING:
143
+ print("Application is unactivated.")
144
+ # Example: prompt user for activation key in CLI
145
+ # key = input("Enter Activation Key: ")
146
+ # success, msg = client.activate_device(key)
147
+
148
+ # 4. Start Background Protection Thread
149
+ # Validates locally and checks online every 5 minutes (300 seconds)
150
+ client.start_monitoring(interval_seconds=300)
151
+
152
+ if __name__ == "__main__":
153
+ main()
154
+ ```
@@ -0,0 +1,7 @@
1
+ licensing_client.py,sha256=-oWG34VUx5TGQDafCMCljH9KTXscECQBbu2G0jkoA1k,17247
2
+ icare_licensing-1.0.0.dist-info/licenses/license.lic,sha256=WG0_fKN8wUai1WkF_Ab5FDOt6j1q8gsnMTC3gTQjIEg,205
3
+ icare_licensing-1.0.0.dist-info/licenses/license_metadata.json,sha256=Lkb9xaIF4vBfNhGykUYIajMekO5Dk0cN7lh6Fr8WjRo,199
4
+ icare_licensing-1.0.0.dist-info/METADATA,sha256=HGbtjKF6kZAwufAHyzCUb5aLEONx_iUqyOyjGbP1ueE,6063
5
+ icare_licensing-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
6
+ icare_licensing-1.0.0.dist-info/top_level.txt,sha256=i-_Wemc8QhosRnFSyK0gVfMETpdwvklOtIZC2jjSM7U,17
7
+ icare_licensing-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ bGljZW5zZUlkPTEwO3N0YXJ0RGF0ZT0yMDI2LTA1LTIyIDE4OjIzOjA5O3ZhbGlkRGF5cz0zNjU7aGFyZHdhcmVJZD1IVy1CNjVBNTBCOEVCMjJGMzk2.W5O/Ffr9TQkLbu0S8von0xktvU8UOkUTcQSveas7Pj1cOScUE3y1X/HsMHUQ3ZlKZUa6iXFU3oWo789tPbKeHw==
@@ -0,0 +1,4 @@
1
+ {
2
+ "public_key": "MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAIxJO7zOoJgqTDHspY+t1jBfBuxce5sOQ0GyrXzuxLW6pp2/lleFdDXyNDaYkdgp8Pl4VW7XjGkL/fTrC5p2LasCAwEAAQ==",
3
+ "last_server_sync": 1779603544.7417145
4
+ }
@@ -0,0 +1 @@
1
+ licensing_client
licensing_client.py ADDED
@@ -0,0 +1,403 @@
1
+ import os
2
+ import json
3
+ import time
4
+ import uuid
5
+ import platform
6
+ import hashlib
7
+ import base64
8
+ import threading
9
+ from datetime import datetime, timedelta
10
+ import requests
11
+
12
+ # Cryptography dependencies for RSA signature verification
13
+ try:
14
+ from cryptography.hazmat.primitives.asymmetric import padding
15
+ from cryptography.hazmat.primitives import hashes
16
+ from cryptography.hazmat.primitives.serialization import load_der_public_key
17
+ CRYPTOGRAPHY_AVAILABLE = True
18
+ except ImportError:
19
+ CRYPTOGRAPHY_AVAILABLE = False
20
+
21
+ # Licensing Status Constants
22
+ STATUS_VALID = "VALID"
23
+ STATUS_EXPIRED = "EXPIRED"
24
+ STATUS_MISSING = "MISSING"
25
+ STATUS_INVALID = "INVALID"
26
+ STATUS_REQUIRES_SYNC = "REQUIRES_SYNC"
27
+ STATUS_UNKNOWN = "UNKNOWN"
28
+
29
+ class LicensingClient:
30
+ def __init__(self, server_url, license_file_path="license.lic", status_callback=None, max_offline_days=3):
31
+ """
32
+ Initializes the Licensing Client.
33
+
34
+ :param server_url: Base URL of the Spring Boot Licensing Server (e.g. 'http://localhost:8081')
35
+ :param license_file_path: File path where the signed compact license key is stored
36
+ :param status_callback: Callable function triggered when the license status transitions
37
+ :param max_offline_days: Number of days the client can run offline before requiring a sync
38
+ """
39
+ self.server_url = server_url.rstrip("/")
40
+ self.license_file_path = license_file_path
41
+ self.status_callback = status_callback
42
+ self.max_offline_days = max_offline_days
43
+
44
+ self.metadata_file_path = f"{os.path.splitext(self.license_file_path)[0]}_metadata.json"
45
+
46
+ self.current_status = STATUS_UNKNOWN
47
+ self.public_key_b64 = None
48
+ self.last_server_sync = 0.0
49
+
50
+ self._stop_event = threading.Event()
51
+ self._lock = threading.Lock()
52
+
53
+ # Load local cached metadata on initialization
54
+ self._load_local_metadata()
55
+
56
+ @staticmethod
57
+ def get_hardware_id():
58
+ """
59
+ Generates a cross-platform, consistent, and unique Hardware ID for the host machine.
60
+
61
+ :return: String representing unique Hardware ID (e.g., 'HW-E8C2F1A039B4725E')
62
+ """
63
+ node = uuid.getnode()
64
+ system = platform.system()
65
+ machine = platform.machine()
66
+ processor = platform.processor()
67
+
68
+ # Combine system parameters into a stable raw string
69
+ system_info = f"{node}-{system}-{machine}-{processor}"
70
+ sha_hash = hashlib.sha256(system_info.encode("utf-8")).hexdigest()
71
+
72
+ return f"HW-{sha_hash[:16].upper()}"
73
+
74
+ def activate_device(self, activation_key):
75
+ """
76
+ [SYNCHRONOUS] Activates the device by binding the activation key placeholder
77
+ to the local machine's generated Hardware ID.
78
+
79
+ :param activation_key: The Base64 placeholder code issued by the admin.
80
+ :return: (bool success, str message)
81
+ """
82
+ hw_id = self.get_hardware_id()
83
+ activation_url = f"{self.server_url}/api/v1/licenses/activate"
84
+ payload = {
85
+ "activationKey": activation_key.strip(),
86
+ "hardwareID": hw_id
87
+ }
88
+
89
+ try:
90
+ # 1. Fetch public key first to ensure we can verify the signature
91
+ self._fetch_public_key()
92
+
93
+ # 2. Call activation endpoint
94
+ response = requests.post(activation_url, json=payload, timeout=8)
95
+ response_json = response.json()
96
+
97
+ if response.status_code == 200 and response_json.get("success"):
98
+ response_list = response_json.get("responseList", [])
99
+ if response_list:
100
+ license_data = response_list[0]
101
+ license_key = license_data.get("licenseKey")
102
+
103
+ if license_key:
104
+ # Save the compact signed license key locally
105
+ with open(self.license_file_path, "w", encoding="utf-8") as f:
106
+ f.write(license_key)
107
+
108
+ # Update metadata status
109
+ self.last_server_sync = time.time()
110
+ self._save_local_metadata()
111
+
112
+ # Recalculate status immediately
113
+ self._run_check()
114
+ return True, "Device bound and activated successfully!"
115
+
116
+ error_message = response_json.get("message", "Device activation failed.")
117
+ return False, error_message
118
+
119
+ except Exception as e:
120
+ return False, f"Activation request failed: {str(e)}"
121
+
122
+ def start_monitoring(self, interval_seconds=300):
123
+ """
124
+ Spawns the periodic licensing verification loop on a background daemon thread.
125
+
126
+ :param interval_seconds: Background check interval in seconds (default is 5 minutes).
127
+ """
128
+ if not CRYPTOGRAPHY_AVAILABLE:
129
+ raise ImportError(
130
+ "The 'cryptography' library is required to run the LicensingClient. "
131
+ "Please install it using: pip install cryptography"
132
+ )
133
+
134
+ self._stop_event.clear()
135
+ monitor_thread = threading.Thread(
136
+ target=self._monitor_loop,
137
+ args=(interval_seconds,),
138
+ daemon=True
139
+ )
140
+ monitor_thread.start()
141
+
142
+ def stop_monitoring(self):
143
+ """Stops the background verification loop cleanly."""
144
+ self._stop_event.set()
145
+
146
+ def _monitor_loop(self, interval):
147
+ """Background thread main monitoring loop."""
148
+ # Perform immediate validation check on startup
149
+ self._run_check()
150
+
151
+ # Loop periodically until thread shutdown event is set
152
+ while not self._stop_event.is_set():
153
+ self._stop_event.wait(interval)
154
+ if not self._stop_event.is_set():
155
+ self._run_check()
156
+
157
+ def _run_check(self, force_online=False):
158
+ """Runs the verification logic and triggers the developer callback on state transition."""
159
+ with self._lock:
160
+ new_status = self._perform_verification(force_online=force_online)
161
+ if new_status != self.current_status:
162
+ self.current_status = new_status
163
+ if self.status_callback:
164
+ try:
165
+ self.status_callback(new_status)
166
+ except Exception as e:
167
+ print(f"[LicensingClient] Error in developer status callback: {e}")
168
+
169
+ def _perform_verification(self, force_online=False):
170
+ """
171
+ Performs full licensing licensing_verification:
172
+ 1. Checks for file presence
173
+ 2. Validates RSA cryptographic signature offline
174
+ 3. Validates hardware mapping and expiry timeline offline
175
+ 4. Calculates force sync-timeout (3 days)
176
+ 5. Synchronizes with server online (every 24 hours or if requires sync)
177
+ """
178
+ # 1. Check if license file exists
179
+ if not os.path.exists(self.license_file_path):
180
+ return STATUS_MISSING
181
+
182
+ try:
183
+ # Read local license file
184
+ with open(self.license_file_path, "r", encoding="utf-8") as f:
185
+ license_key = f.read().strip()
186
+
187
+ parts = license_key.split(".")
188
+ if len(parts) != 2:
189
+ return STATUS_INVALID
190
+
191
+ readable_b64, signature_b64 = parts[0], parts[1]
192
+
193
+ # Decode the base64 readable payload to parse variables
194
+ try:
195
+ payload_str = base64.b64decode(readable_b64).decode("utf-8")
196
+ except Exception:
197
+ return STATUS_INVALID
198
+
199
+ # Parse payload variables (e.g. licenseId=X;startDate=Y;validDays=Z;hardwareId=W)
200
+ params = {}
201
+ for segment in payload_str.split(";"):
202
+ if "=" in segment:
203
+ k, v = segment.split("=", 1)
204
+ params[k] = v
205
+
206
+ required_keys = ["licenseId", "startDate", "validDays", "hardwareId"]
207
+ if not all(k in params for k in required_keys):
208
+ return STATUS_INVALID
209
+
210
+ # 2. Local Hardware Binding Check
211
+ local_hw_id = self.get_hardware_id()
212
+ if params["hardwareId"] != local_hw_id:
213
+ return STATUS_INVALID
214
+
215
+ # 3. Local Expiration Timeline Check
216
+ try:
217
+ start_date = datetime.strptime(params["startDate"], "%Y-%m-%d %H:%M:%S")
218
+ valid_days = int(params["validDays"])
219
+ expiration_date = start_date + timedelta(days=valid_days)
220
+ except Exception:
221
+ return STATUS_INVALID
222
+
223
+ if datetime.now() > expiration_date:
224
+ return STATUS_EXPIRED
225
+
226
+ # Ensure we have the public key loaded
227
+ if not self.public_key_b64:
228
+ # Try fetching from server or metadata cache
229
+ self._fetch_public_key()
230
+ if not self.public_key_b64:
231
+ return STATUS_INVALID
232
+
233
+ # 4. Cryptographic RSA Signature Offline Validation
234
+ signature_valid = self._verify_rsa_signature(readable_b64, signature_b64)
235
+ if not signature_valid:
236
+ return STATUS_INVALID
237
+
238
+ # 5. Online Verification Sync Checks (Scheduled 24h & Sync-Timeout 3d)
239
+ now = time.time()
240
+ elapsed_since_sync = now - self.last_server_sync
241
+
242
+ # 24 Hours in seconds = 86,400 seconds
243
+ sync_interval_elapsed = elapsed_since_sync >= 24 * 3600
244
+
245
+ # Perform online check if:
246
+ # - We are forcing an online check (e.g. manual verification click)
247
+ # - 24 hours have passed since the last successful sync
248
+ if force_online or sync_interval_elapsed:
249
+ sync_result = self._perform_online_sync(license_key)
250
+ if sync_result == "SUCCESS":
251
+ return STATUS_VALID
252
+ elif sync_result == "REJECTED":
253
+ # Actively rejected by the server! Immediately lock and invalidate client!
254
+ # Purge local files and metadata so local offline validation is permanently blocked
255
+ try:
256
+ if os.path.exists(self.license_file_path):
257
+ os.remove(self.license_file_path)
258
+ except Exception as e:
259
+ print(f"[LicensingClient] Failed to delete rejected license file: {e}")
260
+
261
+ try:
262
+ if os.path.exists(self.metadata_file_path):
263
+ os.remove(self.metadata_file_path)
264
+ except Exception as e:
265
+ print(f"[LicensingClient] Failed to delete metadata file: {e}")
266
+
267
+ self.last_server_sync = 0.0
268
+ self.public_key_b64 = None
269
+ return STATUS_INVALID
270
+ # If sync_result is "OFFLINE", we continue to the offline fallback!
271
+
272
+ # 3 Days in seconds = 259,200 seconds
273
+ offline_limit_elapsed = elapsed_since_sync >= self.max_offline_days * 24 * 3600
274
+ if offline_limit_elapsed:
275
+ # We've been offline too long! Force sync status transition.
276
+ return STATUS_REQUIRES_SYNC
277
+
278
+ # Otherwise, the offline verification is completely valid!
279
+ return STATUS_VALID
280
+
281
+ except Exception:
282
+ return STATUS_INVALID
283
+
284
+ def _verify_rsa_signature(self, readable_b64, signature_b64):
285
+ """Cryptographically verifies the SHA256withRSA signature offline using the cached public key."""
286
+ if not CRYPTOGRAPHY_AVAILABLE:
287
+ return False
288
+
289
+ try:
290
+ public_key_bytes = base64.b64decode(self.public_key_b64)
291
+ public_key = load_der_public_key(public_key_bytes)
292
+
293
+ sig_bytes = base64.b64decode(signature_b64)
294
+ payload_bytes = readable_b64.encode("utf-8")
295
+
296
+ public_key.verify(
297
+ sig_bytes,
298
+ payload_bytes,
299
+ padding.PKCS1v15(),
300
+ hashes.SHA256()
301
+ )
302
+ return True
303
+ except Exception:
304
+ return False
305
+
306
+ def _perform_online_sync(self, license_key):
307
+ """
308
+ Pings the online verify endpoint to synchronize license state.
309
+ Returns:
310
+ "SUCCESS" if verified and valid on the server.
311
+ "REJECTED" if server is online and actively rejected it (revoked/deactivated/deleted).
312
+ "OFFLINE" if the server is unreachable (temporary network downtime).
313
+ """
314
+ verify_url = f"{self.server_url}/api/v1/licenses/verify"
315
+ payload = {"licenseKey": license_key}
316
+
317
+ try:
318
+ response = requests.post(verify_url, json=payload, timeout=5)
319
+
320
+ # Case A: Active Success
321
+ if response.status_code == 200:
322
+ try:
323
+ response_json = response.json()
324
+ if response_json.get("success"):
325
+ response_list = response_json.get("responseList", [])
326
+ if response_list:
327
+ server_license = response_list[0]
328
+ # Check if backend reports key is active and not disabled
329
+ if server_license.get("status") == "ACTIVE" and server_license.get("active") == 1:
330
+ # Update sync time and cache
331
+ self.last_server_sync = time.time()
332
+ self._save_local_metadata()
333
+ return "SUCCESS"
334
+ else:
335
+ # Server successfully responded, but license is not active/enabled (e.g. pending, deactivated, expired)
336
+ return "REJECTED"
337
+ except Exception:
338
+ pass
339
+
340
+ # Case B: Active Server Rejection
341
+ # If server actively returns 400 Bad Request or 404 Not Found (e.g. from IllegalArgumentException)
342
+ # or returned a valid JSON with success=False, it means the server processed the key and found it invalid.
343
+ if response.status_code == 400 or response.status_code == 404 or response.status_code == 403:
344
+ return "REJECTED"
345
+
346
+ try:
347
+ response_json = response.json()
348
+ if response_json.get("success") is False:
349
+ return "REJECTED"
350
+ except Exception:
351
+ pass
352
+
353
+ # If the HTTP status is between 400 and 499, treat as REJECTED
354
+ if 400 <= response.status_code < 500:
355
+ return "REJECTED"
356
+
357
+ # Case C: Offline or temporary Server Error
358
+ # Treating 5xx server errors as OFFLINE ensures transient backend issues don't lock out valid paying clients.
359
+ return "OFFLINE"
360
+
361
+ except requests.exceptions.RequestException:
362
+ # Server is unreachable (Connection error, timeout, DNS failure)
363
+ return "OFFLINE"
364
+ except Exception:
365
+ return "OFFLINE"
366
+
367
+ def _fetch_public_key(self):
368
+ """Fetches the active public key from the Spring Boot server and saves it to local cache."""
369
+ public_key_url = f"{self.server_url}/api/v1/licenses/public-key"
370
+ try:
371
+ response = requests.get(public_key_url, timeout=4)
372
+ response_json = response.json()
373
+ if response.status_code == 200 and response_json.get("success"):
374
+ response_list = response_json.get("responseList", [])
375
+ if response_list:
376
+ self.public_key_b64 = response_list[0]
377
+ self._save_local_metadata()
378
+ except Exception:
379
+ # Fail silently to support local cached fallback
380
+ pass
381
+
382
+ def _load_local_metadata(self):
383
+ """Loads cached public key and last sync timestamp from a secure metadata JSON file."""
384
+ if os.path.exists(self.metadata_file_path):
385
+ try:
386
+ with open(self.metadata_file_path, "r", encoding="utf-8") as f:
387
+ data = json.load(f)
388
+ self.public_key_b64 = data.get("public_key")
389
+ self.last_server_sync = data.get("last_server_sync", 0.0)
390
+ except Exception:
391
+ pass
392
+
393
+ def _save_local_metadata(self):
394
+ """Saves current public key and last sync timestamp to local metadata cache."""
395
+ try:
396
+ data = {
397
+ "public_key": self.public_key_b64,
398
+ "last_server_sync": self.last_server_sync
399
+ }
400
+ with open(self.metadata_file_path, "w", encoding="utf-8") as f:
401
+ json.dump(data, f, indent=4)
402
+ except Exception:
403
+ pass