1use crate::clock::{clock_manipulated, clock_rolled_back};
5use crate::http::retry::{MAX_ATTEMPTS, RetryDecision, backoff_ms, clamp_sleep_ms, decide};
6use crate::http::{Transport, TransportOutcome, ureq_transport::UreqTransport};
7use crate::state::{KeylessState, LicenseState, TrialStatus, resolve_state};
8use crate::store::device::{DeviceIdentity, SystemDeviceIdentity};
9use crate::store::{LicenseStore, account, encrypted_file::EncryptedFileStore};
10use crate::{KeylightConfig, KeylightError, Lease, Result, telemetry, verify_lease};
11use serde::Deserialize;
12use std::sync::{Arc, Mutex};
13use std::time::{Duration, Instant};
14
15#[derive(Debug, Clone)]
16pub struct ActivationResult {
17 pub activated: bool,
18 pub instance_id: Option<String>,
19 pub lease: Option<Lease>,
20 pub license_expires_at: Option<i64>,
21 pub error: Option<String>,
22}
23#[derive(Debug, Clone)]
24pub struct ValidationResult {
25 pub valid: bool,
26 pub lease: Option<Lease>,
27 pub license_expires_at: Option<i64>,
28 pub error: Option<String>,
29}
30
31#[derive(Deserialize)]
32struct ActivateResp {
33 activated: bool,
34 instance_id: Option<String>,
35 license_expires_at: Option<i64>,
36 lease: Option<Lease>,
37 error: Option<String>,
38}
39#[derive(Deserialize)]
40struct ValidateResp {
41 #[serde(default)]
46 valid: bool,
47 license_expires_at: Option<i64>,
48 lease: Option<Lease>,
49 error: Option<String>,
50}
51#[derive(Deserialize)]
52struct ErrorResp {
53 error: Option<String>,
54}
55
56pub struct Keylight {
57 config: KeylightConfig,
58 store: Arc<dyn LicenseStore>,
59 transport: Arc<dyn Transport>,
60 device: Arc<dyn DeviceIdentity>,
61 on_event: Option<Box<dyn Fn(crate::state::LicenseLifecycleEvent) + Send + Sync>>,
62 last_active_revalidate_at: Mutex<Option<Instant>>,
66}
67
68impl Keylight {
69 pub fn new(config: KeylightConfig) -> Result<Self> {
71 let ns = format!("{}-{}", config.tenant_id, config.product_id);
72 let store = Arc::new(EncryptedFileStore::new(&ns)?);
73 Ok(Self::with_parts(
74 config,
75 store,
76 Arc::new(UreqTransport::default()),
77 ))
78 }
79 pub fn with_parts(
81 config: KeylightConfig,
82 store: Arc<dyn LicenseStore>,
83 transport: Arc<dyn Transport>,
84 ) -> Self {
85 Self {
86 config,
87 store,
88 transport,
89 device: Arc::new(SystemDeviceIdentity),
90 on_event: None,
91 last_active_revalidate_at: Mutex::new(None),
92 }
93 }
94 pub fn with_event_handler(
96 mut self,
97 handler: impl Fn(crate::state::LicenseLifecycleEvent) + Send + Sync + 'static,
98 ) -> Self {
99 self.on_event = Some(Box::new(handler));
100 self
101 }
102 pub fn with_device(mut self, device: Arc<dyn DeviceIdentity>) -> Self {
105 self.device = device;
106 self
107 }
108
109 fn request_id() -> String {
110 use rand::Rng;
111 let n: u32 = rand::thread_rng().r#gen();
112 format!("{n:08x}")
113 }
114 fn headers(&self) -> Vec<(String, String)> {
115 let mut h = vec![
116 ("Content-Type".into(), "application/json".into()),
117 ("X-Keylight-Request-Id".into(), Self::request_id()),
118 ];
119 if !self.config.sdk_key.is_empty() {
120 h.push(("X-Keylight-SDK-Key".into(), self.config.sdk_key.clone()));
121 }
122 h
123 }
124 fn body_with_telemetry(&self, mut map: serde_json::Map<String, serde_json::Value>) -> String {
125 telemetry::apply(&mut map, self.config.app_version.as_deref());
126 serde_json::Value::Object(map).to_string()
127 }
128
129 fn cached_hardware_id(&self) -> Option<String> {
134 match self.device.hardware_id() {
135 Some(hw) => {
136 let _ = self.store.set_string(account::CACHED_HARDWARE_ID, &hw);
137 Some(hw)
138 }
139 None => self.store.get_string(account::CACHED_HARDWARE_ID),
140 }
141 }
142 fn machine_hash(&self) -> Option<String> {
144 self.cached_hardware_id().map(|hw| {
145 crate::machine::machine_hash(&self.config.tenant_id, &self.config.product_id, &hw)
146 })
147 }
148
149 fn post(&self, path: &str, body: &str, decodable_4xx: &[u16]) -> Result<(u16, String)> {
151 let url = self.api_url(path);
152 let headers = self.headers();
153 let mut attempt = 0u32;
154 loop {
155 attempt += 1;
156 match self.transport.post_json(&url, &headers, body) {
157 TransportOutcome::Response(r) => {
158 if r.status == 200 || decodable_4xx.contains(&r.status) {
159 return Ok((r.status, r.body));
160 }
161 match decide(r.status, attempt, r.retry_after) {
162 RetryDecision::RetryAfter(ms) => {
163 std::thread::sleep(std::time::Duration::from_millis(ms + jitter_ms()));
164 continue;
165 }
166 RetryDecision::Stop => {
167 if r.status == 429 {
168 return Err(KeylightError::RateLimited {
169 retry_after: r.retry_after.unwrap_or(0),
170 });
171 }
172 if (500..=599).contains(&r.status) || r.status == 408 {
173 return Err(KeylightError::ServerError { status: r.status });
174 }
175 let msg = serde_json::from_str::<ErrorResp>(&r.body)
176 .ok()
177 .and_then(|e| e.error)
178 .unwrap_or_default();
179 return Err(KeylightError::ClientError {
180 status: r.status,
181 message: msg,
182 });
183 }
184 }
185 }
186 TransportOutcome::Transient(_) if attempt < MAX_ATTEMPTS => {
187 std::thread::sleep(std::time::Duration::from_millis(
188 clamp_sleep_ms(backoff_ms(attempt)) + jitter_ms(),
189 ));
190 continue;
191 }
192 TransportOutcome::Transient(e) | TransportOutcome::Terminal(e) => {
193 return Err(KeylightError::NetworkFailure(e));
194 }
195 TransportOutcome::Timeout => return Err(KeylightError::Timeout),
196 }
197 }
198 }
199
200 fn now() -> i64 {
201 std::time::SystemTime::now()
202 .duration_since(std::time::UNIX_EPOCH)
203 .map(|d| d.as_secs() as i64)
204 .unwrap_or(0)
205 }
206
207 fn api_url(&self, path: &str) -> String {
208 format!(
209 "{}/{}/{}/{}",
210 self.config.base_url, self.config.tenant_id, self.config.product_id, path
211 )
212 }
213
214 fn verify(&self, lease: &Lease) -> crate::VerifyResult {
216 verify_lease(
217 lease,
218 &self.config.trusted_keys,
219 Self::now(),
220 crate::SKEW_SECONDS,
221 )
222 }
223
224 fn verify_or_reject(&self, lease: &Lease) -> Result<()> {
225 if self.verify(lease).is_trusted() {
226 Ok(())
227 } else {
228 Err(KeylightError::LeaseVerificationFailed)
229 }
230 }
231
232 pub fn activate(&self, key: &str) -> Result<ActivationResult> {
233 if !self.config.validate_key_format(key) {
234 return Ok(ActivationResult {
235 activated: false,
236 instance_id: None,
237 lease: None,
238 license_expires_at: None,
239 error: Some("Invalid license key format".into()),
240 });
241 }
242 let machine = machine_name();
243 let mut map = serde_json::Map::new();
244 map.insert("license_key".into(), key.into());
245 map.insert("instance_name".into(), machine.into());
246 if let Some(ft) = self.store.get_string(account::FREE_TIER_INSTANCE_ID) {
247 map.insert("free_tier_instance_id".into(), ft.into());
248 }
249 if let Some(hash) = self.machine_hash() {
250 map.insert("machine_hash".into(), hash.into());
251 }
252 let body = self.body_with_telemetry(map);
253
254 let (_, text) = match self.post("activate", &body, &[]) {
255 Ok(v) => v,
256 Err(KeylightError::ClientError { status, message }) => {
257 return Ok(ActivationResult {
258 activated: false,
259 instance_id: None,
260 lease: None,
261 license_expires_at: None,
262 error: Some(if message.is_empty() {
263 format!("Activation failed (HTTP {status})")
264 } else {
265 message
266 }),
267 });
268 }
269 Err(e) => return Err(e),
270 };
271 let resp: ActivateResp =
272 serde_json::from_str(&text).map_err(|_| KeylightError::InvalidResponse)?;
273 if !resp.activated {
274 return Ok(ActivationResult {
275 activated: false,
276 instance_id: None,
277 lease: None,
278 license_expires_at: None,
279 error: resp.error.or(Some("Activation failed".into())),
280 });
281 }
282 if let Some(lease) = &resp.lease {
283 self.verify_or_reject(lease)?;
284 }
285
286 self.store.set_string(account::LICENSE_KEY, key)?;
287 if let Some(id) = &resp.instance_id {
288 self.store.set_string(account::INSTANCE_ID, id)?;
289 }
290 if let Some(lease) = &resp.lease {
291 self.store_lease(lease)?;
292 }
293 self.save_expiry(resp.license_expires_at)?;
294 self.touch_last_seen()?;
295 self.touch_validated_online()?;
296 Ok(ActivationResult {
297 activated: true,
298 instance_id: resp.instance_id,
299 lease: resp.lease,
300 license_expires_at: resp.license_expires_at,
301 error: None,
302 })
303 }
304
305 pub fn validate(&self) -> Result<ValidationResult> {
306 let key = self
307 .store
308 .get_string(account::LICENSE_KEY)
309 .ok_or(KeylightError::NoStoredLicense)?;
310 let instance = self
311 .store
312 .get_string(account::INSTANCE_ID)
313 .ok_or(KeylightError::NoStoredLicense)?;
314 let prev_state = self.state();
315 let prev_expiry = self.store.get_i64(account::LICENSE_EXPIRES_AT);
316 let mut map = serde_json::Map::new();
317 map.insert("license_key".into(), key.into());
318 map.insert("instance_id".into(), instance.into());
319 if let Some(hash) = self.machine_hash() {
320 map.insert("machine_hash".into(), hash.into());
321 }
322 let body = self.body_with_telemetry(map);
323
324 let (_status, text) = match self.post("validate", &body, &[422]) {
325 Ok(v) => v,
326 Err(KeylightError::ClientError { status, message }) => {
327 return Ok(ValidationResult {
328 valid: false,
329 lease: None,
330 license_expires_at: None,
331 error: Some(if message.is_empty() {
332 format!("Validation failed (HTTP {status})")
333 } else {
334 message
335 }),
336 });
337 }
338 Err(e) => return Err(e),
339 };
340 let resp: ValidateResp =
341 serde_json::from_str(&text).map_err(|_| KeylightError::InvalidResponse)?;
342 if let Some(lease) = &resp.lease {
343 self.verify_or_reject(lease)?;
344 }
345 if !resp.valid {
346 match &resp.lease {
353 Some(lease) => self.store_lease(lease)?,
354 None => self.store.delete(account::LEASE)?,
355 }
356 self.save_expiry(resp.license_expires_at)?;
357 self.emit_lifecycle(&prev_state, prev_expiry);
358 return Ok(ValidationResult {
359 valid: false,
360 lease: resp.lease,
361 license_expires_at: resp.license_expires_at,
362 error: resp.error,
363 });
364 }
365 if let Some(lease) = &resp.lease {
366 self.store_lease(lease)?;
367 }
368 self.save_expiry(resp.license_expires_at)?;
369 self.touch_last_seen()?;
370 self.touch_validated_online()?;
371 self.emit_lifecycle(&prev_state, prev_expiry);
372 Ok(ValidationResult {
373 valid: true,
374 lease: resp.lease,
375 license_expires_at: resp.license_expires_at,
376 error: None,
377 })
378 }
379
380 pub fn deactivate(&self) -> Result<()> {
381 let key = self.store.get_string(account::LICENSE_KEY);
382 let instance = self.store.get_string(account::INSTANCE_ID);
383 let mut net_err = None;
384 if let (Some(k), Some(i)) = (key, instance) {
385 let mut map = serde_json::Map::new();
386 map.insert("license_key".into(), k.into());
387 map.insert("instance_id".into(), i.into());
388 let body = self.body_with_telemetry(map);
389 if let Err(e) = self.post("deactivate", &body, &[]) {
390 net_err = Some(e);
391 }
392 }
393 for a in [
394 account::LICENSE_KEY,
395 account::INSTANCE_ID,
396 account::LEASE,
397 account::LICENSE_EXPIRES_AT,
398 account::LAST_VALIDATED_ONLINE,
399 account::LAST_SEEN,
400 ] {
401 self.store.delete(a)?;
402 }
403 net_err.map_or(Ok(()), Err)
404 }
405
406 pub fn cached_lease(&self) -> Option<Lease> {
407 if let Some(max_days) = self.config.max_offline_days {
408 let last = self.store.get_i64(account::LAST_VALIDATED_ONLINE)?;
409 if Self::now() - last > (max_days as i64) * 86400 {
410 return None;
411 }
412 }
413 let lease: Lease = serde_json::from_str(&self.store.get_string(account::LEASE)?).ok()?;
414 let r = self.verify(&lease);
415 if r.is_trusted() && !r.expired && lease.status != "expired" {
416 Some(lease)
417 } else {
418 None
419 }
420 }
421
422 pub fn has_entitlement(&self, feature: &str) -> bool {
423 self.cached_lease()
424 .map(|l| l.entitlements.iter().any(|e| e == feature))
425 .unwrap_or(false)
426 }
427 pub fn has_stored_license(&self) -> bool {
428 self.store.get_string(account::LICENSE_KEY).is_some()
429 }
430 pub fn cached_license_key(&self) -> Option<String> {
431 self.store.get_string(account::LICENSE_KEY)
432 }
433 pub fn cached_license_expires_at(&self) -> Option<i64> {
436 self.store.get_i64(account::LICENSE_EXPIRES_AT)
437 }
438
439 fn store_lease(&self, lease: &Lease) -> Result<()> {
443 let json = serde_json::to_string(lease).expect("Lease serializes to JSON infallibly");
444 self.store.set_string(account::LEASE, &json)
445 }
446 fn save_expiry(&self, e: Option<i64>) -> Result<()> {
447 match e {
448 Some(v) => self
449 .store
450 .set_string(account::LICENSE_EXPIRES_AT, &v.to_string()),
451 None => self.store.delete(account::LICENSE_EXPIRES_AT),
452 }
453 }
454 fn touch_last_seen(&self) -> Result<()> {
455 self.store
456 .set_string(account::LAST_SEEN, &Self::now().to_string())
457 }
458 fn touch_validated_online(&self) -> Result<()> {
459 self.store
460 .set_string(account::LAST_VALIDATED_ONLINE, &Self::now().to_string())
461 }
462}
463
464impl Keylight {
465 pub fn start_trial(&self) -> Result<()> {
466 if self.store.get_string(account::TRIAL_START).is_none() {
467 self.store
468 .set_string(account::TRIAL_START, &Self::now().to_string())?;
469 }
470 if self
471 .store
472 .get_string(account::FREE_TIER_INSTANCE_ID)
473 .is_none()
474 {
475 self.store.set_string(
476 account::FREE_TIER_INSTANCE_ID,
477 &crate::store::device::uuid_v4_pub(),
478 )?;
479 }
480 Ok(())
481 }
482 pub fn check_trial(&self) -> TrialStatus {
483 let start = match self.store.get_i64(account::TRIAL_START) {
484 Some(v) => v,
485 None => return TrialStatus::NotStarted,
486 };
487 let days_elapsed = (Self::now() - start) / 86400;
488 let days_left = self.config.trial_duration_days as i64 - days_elapsed;
489 if days_left > 0 {
490 TrialStatus::Active { days_left }
491 } else {
492 TrialStatus::Expired
493 }
494 }
495 pub fn is_clock_manipulated(&self) -> bool {
496 let manipulated = self
497 .store
498 .get_i64(account::LAST_SEEN)
499 .is_some_and(|last| clock_manipulated(last, Self::now()));
500 if !manipulated {
501 let _ = self.touch_last_seen();
502 }
503 manipulated
504 }
505 pub fn free_tier_instance_id(&self) -> Result<String> {
506 if let Some(id) = self.store.get_string(account::FREE_TIER_INSTANCE_ID) {
507 return Ok(id);
508 }
509 let id = crate::store::device::uuid_v4_pub();
510 self.store.set_string(account::FREE_TIER_INSTANCE_ID, &id)?;
511 Ok(id)
512 }
513 pub fn report_keyless_state(&self, state: KeylessState) {
515 let last_state = self.store.get_string(account::KEYLESS_LAST_STATE);
516 let last_ping = self.store.get_i64(account::LAST_KEYLESS_PING_AT);
517 let changed = last_state.as_deref() != Some(state.wire());
518 let within = last_ping.map(|t| Self::now() - t < 86400).unwrap_or(false);
519 if !changed && within {
520 return;
521 }
522 let instance = match self.free_tier_instance_id() {
523 Ok(i) => i,
524 Err(_) => return,
525 };
526 let mut map = serde_json::Map::new();
527 map.insert("instance_id".into(), instance.into());
528 map.insert("state".into(), state.wire().into());
529 if let Some(hash) = self.machine_hash() {
530 map.insert("machine_hash".into(), hash.into());
531 }
532 let body = self.body_with_telemetry(map);
533 if self.post("keyless", &body, &[]).is_ok() {
537 let _ = self
538 .store
539 .set_string(account::KEYLESS_LAST_STATE, state.wire());
540 let _ = self
541 .store
542 .set_string(account::LAST_KEYLESS_PING_AT, &Self::now().to_string());
543 }
544 }
545 pub fn state(&self) -> LicenseState {
547 if self
554 .store
555 .get_i64(account::LAST_SEEN)
556 .is_some_and(|last| clock_rolled_back(last, Self::now()))
557 {
558 return LicenseState::Invalid;
559 }
560 let offline_bound_ok = match self.config.max_offline_days {
574 Some(max_days) => self
575 .store
576 .get_i64(account::LAST_VALIDATED_ONLINE)
577 .is_some_and(|last| Self::now() - last <= (max_days as i64) * 86400),
578 None => true,
579 };
580 let lease = self
581 .store
582 .get_string(account::LEASE)
583 .and_then(|s| serde_json::from_str::<Lease>(&s).ok());
584 let (status, current) = match &lease {
585 Some(l) if offline_bound_ok => {
586 let r = self.verify(l);
587 (r.is_trusted().then(|| l.status.clone()), !r.expired)
588 }
589 _ => (None, false),
590 };
591 resolve_state(
592 status.as_deref(),
593 current,
594 self.has_stored_license(),
595 &self.check_trial(),
596 self.config.free_tier_enabled,
597 )
598 }
599}
600
601impl Keylight {
602 pub fn refresh_if_needed(&self) -> Result<Option<ValidationResult>> {
604 if !self.has_stored_license() {
605 return Ok(None);
606 }
607 if let Some(last) = self.store.get_i64(account::LAST_VALIDATED_ONLINE) {
608 let now = Self::now();
609 if now - last < REFRESH_DEBOUNCE {
610 return Ok(None);
611 }
612 let near_expiry = self
613 .store
614 .get_i64(account::LICENSE_EXPIRES_AT)
615 .is_some_and(|exp| exp - now < 86400);
616 if now - last < REFRESH_STALE && !near_expiry {
617 return Ok(None);
618 }
619 }
620 Ok(Some(self.validate()?))
621 }
622 pub fn check_on_launch(&self) -> Result<()> {
630 if self.has_stored_license() {
631 let _ = self.validate()?;
632 }
633 Ok(())
634 }
635 pub fn active_revalidate(&self) -> Option<ValidationResult> {
664 if !self.has_stored_license() {
665 return None;
666 }
667 {
668 let mut last = self
672 .last_active_revalidate_at
673 .lock()
674 .unwrap_or_else(|e| e.into_inner());
675 if last.is_some_and(|t| t.elapsed() < ACTIVE_REVALIDATE_DEBOUNCE) {
676 return None;
677 }
678 *last = Some(Instant::now());
679 }
680 self.validate().ok()
681 }
682
683 pub fn upgrade_url(&self) -> Option<String> {
685 let key = self.cached_license_key()?;
686 Some(format!(
687 "https://portal.keylight.dev/p/{}/upgrade/{}?key={}",
688 self.config.tenant_id,
689 self.config.product_id,
690 urlencode(&key)
691 ))
692 }
693
694 fn emit_lifecycle(&self, prev_state: &LicenseState, prev_expiry: Option<i64>) {
698 let next_state = self.state();
699 let expiry_moved_later = self.store.get_i64(account::LICENSE_EXPIRES_AT) > prev_expiry;
702 if let Some(ev) = crate::state::lifecycle_event(prev_state, &next_state, expiry_moved_later)
703 {
704 if let Some(h) = &self.on_event {
705 h(ev);
706 }
707 }
708 }
709}
710
711const REFRESH_DEBOUNCE: i64 = 300; const REFRESH_STALE: i64 = 21600; const ACTIVE_REVALIDATE_DEBOUNCE: Duration = Duration::from_secs(60);
715
716fn urlencode(s: &str) -> String {
717 use std::fmt::Write;
718 let mut out = String::with_capacity(s.len());
719 for b in s.bytes() {
720 match b {
721 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
722 out.push(b as char)
723 }
724 _ => {
725 let _ = write!(out, "%{b:02X}");
726 }
727 }
728 }
729 out
730}
731
732fn machine_name() -> String {
736 for var in ["HOSTNAME", "COMPUTERNAME", "HOST"] {
737 if let Ok(v) = std::env::var(var) {
738 let v = v.trim().to_string();
739 if !v.is_empty() {
740 return v;
741 }
742 }
743 }
744 if let Ok(out) = std::process::Command::new("hostname").output() {
745 let v = String::from_utf8_lossy(&out.stdout).trim().to_string();
746 if !v.is_empty() {
747 return v;
748 }
749 }
750 "device".to_string()
751}
752
753fn jitter_ms() -> u64 {
756 use rand::Rng;
757 rand::thread_rng().gen_range(0..250)
758}