Skip to content

Commit cf4f913

Browse files
committed
Fair use: Add device tracking for abuse detection
- Add fair use clause to ToS (sections 1, 2, 3) and clarify "no usage telemetry" in privacy policy - New `device_id.rs`: reads `IOPlatformUUID` via IOKit FFI, salts with `"cmdr:"`, SHA-256 hashes to `v1:<hex>`, cached in `OnceLock` - Send `deviceId` on every `/validate` call (piggybacks on existing 7-day validation, no new network calls) - License server tracks device sets in KV (`devices:{seatTransactionId}`), prunes entries >90 days - Alert email via Resend to `legal@getcmdr.com` when a key crosses 6 devices (suppressed for 30 days after each alert) - Device tracking runs via `waitUntil` — zero added latency to validation responses - Analytics Engine dataset `cmdr_device_counts` for retroactive querying - Extended `SubscriptionResult` with `customerId` for alert customer lookup - Pure helpers in `device-tracking.ts` with full test coverage - Updated CLAUDE.md files for both licensing module and license server
1 parent f9855ca commit cf4f913

19 files changed

Lines changed: 791 additions & 47 deletions

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/src-tauri/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ tauri-plugin-fs = "2.4.4"
5252
alphanumeric-sort = "1.5"
5353
chrono = "0.4"
5454
ed25519-dalek = { version = "2.1", features = ["rand_core"] }
55+
# Device ID hashing for fair-use license tracking
56+
sha2 = "0.10.9"
5557
tauri-plugin-log = "2"
5658
log = "0.4"
5759
# Memory allocator: returns memory to OS faster than system malloc, reduces fragmentation

apps/desktop/src-tauri/src/licensing/CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ License keys are self-contained: `base64(JSON payload).base64(Ed25519 signature)
1111
| `mod.rs` | `LicenseData` struct, `redact_email` helper, re-exports from sub-modules |
1212
| `verification.rs` | Ed25519 crypto. `LicenseActivationError` typed error enum. Validates key format, verifies signature, caches result in `Mutex`. Split into verify/commit: `verify_license_async` (read-only check + short-code exchange), `commit_license` (persist to disk + update caches). Legacy wrappers: `activate_license` (sync verify+commit), `activate_license_async` (async verify+commit). `get_license_info` (lazy, cached). `VerifyResult` struct wraps `LicenseInfo` + `full_key` + `short_code`. |
1313
| `app_status.rs` | `AppStatus` enum. 7-day server re-validation, 30-day offline grace period. Commercial use reminder timer. Debug: `CMDR_MOCK_LICENSE` env var overrides everything. |
14-
| `validation_client.rs` | HTTP client: `POST /validate`, `POST /activate`. Debug → `localhost:8787`, release → `license.getcmdr.com`. Mock mode skips network entirely. Returns `ValidationOutcome` enum (Success/UpstreamError/NetworkError). |
14+
| `validation_client.rs` | HTTP client: `POST /validate`, `POST /activate`. Debug → `localhost:8787`, release → `license.getcmdr.com`. Mock mode skips network entirely. Returns `ValidationOutcome` enum (Success/UpstreamError/NetworkError). `ValidationRequest` includes an optional `deviceId` for fair-use tracking. |
15+
| `device_id.rs` | Stable hashed device identifier for fair-use license tracking. Reads `IOPlatformUUID` via IOKit FFI (macOS), salts with `"cmdr:"`, SHA-256 hashes, prefixes with `v1:`. Cached in `OnceLock`. Returns `None` on failure (best-effort, never blocks validation). Linux stub returns `None`. |
1516

1617
## AppStatus variants
1718

@@ -120,5 +121,5 @@ Legacy `activate_license`/`activate_license_async` wrappers still exist for back
120121

121122
## Dependencies
122123

123-
External: `ed25519-dalek`, `base64`, `reqwest`, `tauri_plugin_store`
124+
External: `ed25519-dalek`, `base64`, `reqwest`, `tauri_plugin_store`, `sha2`, `core-foundation` (macOS)
124125
Internal: none
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
//! Stable, hashed device identifier for fair-use license tracking.
2+
//!
3+
//! Generates a one-way hash of the hardware UUID, prefixed with a version tag.
4+
//! The hash is salted with `"cmdr:"` so it can't be correlated across products.
5+
//! Result format: `v1:<64-char hex SHA-256>`.
6+
7+
use sha2::{Digest, Sha256};
8+
use std::sync::OnceLock;
9+
10+
/// Cached device ID (computed once per session).
11+
static DEVICE_ID: OnceLock<Option<String>> = OnceLock::new();
12+
13+
/// Returns a stable, hashed device identifier, or `None` if the platform UUID can't be read.
14+
///
15+
/// The result is cached in memory — the hardware UUID won't change during a session.
16+
pub fn get_device_id() -> Option<String> {
17+
DEVICE_ID.get_or_init(compute_device_id).clone()
18+
}
19+
20+
fn compute_device_id() -> Option<String> {
21+
let uuid = read_platform_uuid()?;
22+
let salted = format!("cmdr:{uuid}");
23+
let hash = Sha256::digest(salted.as_bytes());
24+
Some(format!("v1:{:x}", hash))
25+
}
26+
27+
/// Read `IOPlatformUUID` from the IOKit registry via FFI.
28+
#[cfg(target_os = "macos")]
29+
fn read_platform_uuid() -> Option<String> {
30+
use core_foundation::base::TCFType;
31+
use core_foundation::string::{CFString, CFStringRef};
32+
use std::ffi::c_void;
33+
34+
#[link(name = "IOKit", kind = "framework")]
35+
unsafe extern "C" {
36+
fn IOServiceGetMatchingService(main_port: u32, matching: *const c_void) -> u32;
37+
fn IOServiceMatching(name: *const std::ffi::c_char) -> *const c_void;
38+
fn IORegistryEntryCreateCFProperty(
39+
entry: u32,
40+
key: CFStringRef,
41+
allocator: *const c_void,
42+
options: u32,
43+
) -> *const c_void;
44+
fn IOObjectRelease(object: u32) -> i32;
45+
}
46+
47+
unsafe {
48+
let matching = IOServiceMatching(c"IOPlatformExpertDevice".as_ptr());
49+
if matching.is_null() {
50+
log::warn!("IOServiceMatching returned null");
51+
return None;
52+
}
53+
54+
// kIOMasterPortDefault / kIOMainPortDefault = 0
55+
let service = IOServiceGetMatchingService(0, matching);
56+
// IOServiceMatching result is consumed by IOServiceGetMatchingService — don't CFRelease it.
57+
if service == 0 {
58+
log::warn!("IOServiceGetMatchingService found no platform expert");
59+
return None;
60+
}
61+
62+
let key = CFString::new("IOPlatformUUID");
63+
let cf_value = IORegistryEntryCreateCFProperty(service, key.as_concrete_TypeRef(), std::ptr::null(), 0);
64+
IOObjectRelease(service);
65+
66+
if cf_value.is_null() {
67+
log::warn!("IORegistryEntryCreateCFProperty returned null for IOPlatformUUID");
68+
return None;
69+
}
70+
71+
let cf_string = CFString::wrap_under_create_rule(cf_value as CFStringRef);
72+
Some(cf_string.to_string())
73+
}
74+
}
75+
76+
/// Linux stub — returns `None` for now.
77+
// TODO: Read `/etc/machine-id`, apply the same salt-and-hash approach as macOS.
78+
#[cfg(target_os = "linux")]
79+
fn read_platform_uuid() -> Option<String> {
80+
None
81+
}
82+
83+
#[cfg(test)]
84+
mod tests {
85+
use super::*;
86+
87+
#[test]
88+
#[cfg(target_os = "macos")]
89+
fn returns_some_on_macos() {
90+
let id = get_device_id();
91+
assert!(id.is_some(), "get_device_id() should return Some on macOS");
92+
}
93+
94+
#[test]
95+
#[cfg(target_os = "macos")]
96+
fn matches_expected_format() {
97+
let id = get_device_id().expect("should return Some on macOS");
98+
assert!(id.starts_with("v1:"), "should start with 'v1:' prefix, got: {id}");
99+
let hex_part = &id[3..];
100+
assert_eq!(
101+
hex_part.len(),
102+
64,
103+
"hex part should be 64 chars, got: {}",
104+
hex_part.len()
105+
);
106+
assert!(
107+
hex_part.chars().all(|c| c.is_ascii_hexdigit()),
108+
"hex part should be lowercase hex, got: {hex_part}"
109+
);
110+
}
111+
112+
#[test]
113+
#[cfg(target_os = "macos")]
114+
fn returns_stable_value() {
115+
let first = get_device_id();
116+
let second = get_device_id();
117+
assert_eq!(
118+
first, second,
119+
"get_device_id() should return the same value on repeated calls"
120+
);
121+
}
122+
123+
#[test]
124+
fn hash_is_deterministic() {
125+
// Verify the hashing logic directly (platform-independent).
126+
let uuid = "TEST-UUID-1234";
127+
let salted = format!("cmdr:{uuid}");
128+
let hash = Sha256::digest(salted.as_bytes());
129+
let result = format!("v1:{:x}", hash);
130+
131+
let salted2 = format!("cmdr:{uuid}");
132+
let hash2 = Sha256::digest(salted2.as_bytes());
133+
let result2 = format!("v1:{:x}", hash2);
134+
135+
assert_eq!(result, result2);
136+
assert!(result.starts_with("v1:"));
137+
assert_eq!(result.len(), 3 + 64); // "v1:" + 64 hex chars
138+
}
139+
}

apps/desktop/src-tauri/src/licensing/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
//! The public key is embedded at compile time.
55
66
mod app_status;
7+
mod device_id;
78
mod validation_client;
89
mod verification;
910

apps/desktop/src-tauri/src/licensing/validation_client.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ pub enum ValidationOutcome {
4040
#[serde(rename_all = "camelCase")]
4141
struct ValidationRequest {
4242
transaction_id: String,
43+
/// Hashed device identifier for fair-use tracking. `None` if the platform UUID couldn't be read.
44+
device_id: Option<String>,
4345
}
4446

4547
/// Response from the /activate endpoint.
@@ -152,6 +154,7 @@ pub async fn validate_with_server(transaction_id: &str) -> ValidationOutcome {
152154
.post(&url)
153155
.json(&ValidationRequest {
154156
transaction_id: transaction_id.to_string(),
157+
device_id: super::device_id::get_device_id(),
155158
})
156159
.send()
157160
.await
@@ -193,9 +196,22 @@ mod tests {
193196
fn test_validation_request_serialization() {
194197
let request = ValidationRequest {
195198
transaction_id: "txn_123".to_string(),
199+
device_id: None,
196200
};
197201
let json = serde_json::to_string(&request).unwrap();
198202
assert!(json.contains("\"transactionId\":\"txn_123\""));
203+
assert!(json.contains("\"deviceId\":null"));
204+
}
205+
206+
#[test]
207+
fn test_validation_request_serialization_with_device_id() {
208+
let request = ValidationRequest {
209+
transaction_id: "txn_456".to_string(),
210+
device_id: Some("v1:abc123".to_string()),
211+
};
212+
let json = serde_json::to_string(&request).unwrap();
213+
assert!(json.contains("\"transactionId\":\"txn_456\""));
214+
assert!(json.contains("\"deviceId\":\"v1:abc123\""));
199215
}
200216

201217
#[test]

apps/license-server/CLAUDE.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ license keys, stores short activation codes in KV, and emails keys via Resend.
1212
| `src/paddle.ts` | HMAC-SHA256 webhook verification, `constantTimeEqual` |
1313
| `src/paddle-api.ts` | Paddle REST client: transaction/subscription/customer fetch |
1414
| `src/email.ts` | Resend email delivery (HTML + plain text, multi-seat support) |
15+
| `src/device-tracking.ts` | Device set helpers: prune stale devices, alert threshold |
1516
| `src/license.test.ts`, `src/paddle.test.ts` | Vitest tests |
17+
| `src/device-tracking.test.ts` | Tests for device tracking helpers |
1618
| `scripts/generate-keys.js` | Ed25519 key pair generation (run once at setup) |
1719
| `scripts/setup-cf-infra.sh` | Cloudflare KV namespace provisioning |
1820

@@ -74,6 +76,8 @@ App activation: POST /activate → KV.get(shortCode) → return fullKey
7476
Subscription validation: POST /validate → Paddle API transactions + subscriptions
7577
→ HTTP 200 + ValidationResponse on success or invalid transaction (Paddle 404)
7678
→ HTTP 502 + { error: "upstream_error" } if Paddle API unreachable or returns server error
79+
→ if deviceId present: track device in KV (devices:{seatTransactionId}), log to Analytics Engine
80+
→ if device count >= 6 and not recently alerted: send alert email to legal@getcmdr.com
7781
7882
Download redirect: GET /download/:version/:arch → log to Analytics Engine → 302 to GitHub Releases
7983
```
@@ -110,6 +114,13 @@ status on transient Paddle outages instead of overwriting a valid "active" cache
110114
`writeDataPoint` is fire-and-forget. Data schema: indexes=[version], blobs=[version, arch, country, continent],
111115
doubles=[1]. Query via CF Analytics Engine SQL API.
112116

117+
**Device tracking (fair use):** On each `/validate` call with a `deviceId`, the server tracks the device in KV
118+
(`devices:{seatTransactionId}`) and logs to Analytics Engine (binding: `DEVICE_COUNTS`, dataset: `cmdr_device_counts`).
119+
Devices older than 90 days are pruned on each write. If 6+ devices are active and no alert was sent in the past 30 days,
120+
an internal email is sent to `legal@getcmdr.com` via Resend. Device tracking is fire-and-forget and never affects the
121+
validation response. The KV value stores a `DeviceSet` with device hashes mapped to last-seen timestamps plus an
122+
optional `lastAlertedAt`. See `docs/specs/fair-use-device-tracking-plan.md` for the full plan.
123+
113124
## Local development
114125

115126
### Run locally
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import { pruneStaleDevices, shouldAlert } from './device-tracking'
3+
4+
describe('pruneStaleDevices', () => {
5+
it('removes entries older than maxAgeDays', () => {
6+
const now = Date.now()
7+
const devices: Record<string, string> = {
8+
'device-old': new Date(now - 91 * 24 * 60 * 60 * 1000).toISOString(),
9+
'device-recent': new Date(now - 10 * 24 * 60 * 60 * 1000).toISOString(),
10+
}
11+
12+
const result = pruneStaleDevices(devices, 90)
13+
14+
expect(Object.keys(result)).toEqual(['device-recent'])
15+
})
16+
17+
it('keeps entries within maxAgeDays', () => {
18+
const now = Date.now()
19+
const devices: Record<string, string> = {
20+
'device-a': new Date(now - 1 * 24 * 60 * 60 * 1000).toISOString(),
21+
'device-b': new Date(now - 45 * 24 * 60 * 60 * 1000).toISOString(),
22+
'device-c': new Date(now - 89 * 24 * 60 * 60 * 1000).toISOString(),
23+
}
24+
25+
const result = pruneStaleDevices(devices, 90)
26+
27+
expect(Object.keys(result)).toHaveLength(3)
28+
})
29+
30+
it('returns empty object when all entries are stale', () => {
31+
const now = Date.now()
32+
const devices: Record<string, string> = {
33+
'device-a': new Date(now - 100 * 24 * 60 * 60 * 1000).toISOString(),
34+
'device-b': new Date(now - 200 * 24 * 60 * 60 * 1000).toISOString(),
35+
}
36+
37+
const result = pruneStaleDevices(devices, 90)
38+
39+
expect(Object.keys(result)).toHaveLength(0)
40+
})
41+
42+
it('handles empty input', () => {
43+
const result = pruneStaleDevices({}, 90)
44+
expect(Object.keys(result)).toHaveLength(0)
45+
})
46+
})
47+
48+
describe('shouldAlert', () => {
49+
it('fires when count >= threshold and no previous alert', () => {
50+
expect(shouldAlert(6, undefined, 6)).toBe(true)
51+
expect(shouldAlert(10, undefined, 6)).toBe(true)
52+
})
53+
54+
it('fires when count >= threshold and last alert was >30 days ago', () => {
55+
const thirtyOneDaysAgo = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000).toISOString()
56+
expect(shouldAlert(6, thirtyOneDaysAgo, 6)).toBe(true)
57+
})
58+
59+
it('suppressed when last alert was recent', () => {
60+
const fiveDaysAgo = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString()
61+
expect(shouldAlert(6, fiveDaysAgo, 6)).toBe(false)
62+
expect(shouldAlert(10, fiveDaysAgo, 6)).toBe(false)
63+
})
64+
65+
it('suppressed when count < threshold', () => {
66+
expect(shouldAlert(5, undefined, 6)).toBe(false)
67+
expect(shouldAlert(1, undefined, 6)).toBe(false)
68+
expect(shouldAlert(0, undefined, 6)).toBe(false)
69+
})
70+
71+
it('fires at exactly 30 days boundary', () => {
72+
// At exactly 30 days (not >30), should be suppressed
73+
vi.useFakeTimers()
74+
const exactlyThirtyDays = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
75+
expect(shouldAlert(6, exactlyThirtyDays, 6)).toBe(false)
76+
vi.useRealTimers()
77+
})
78+
})
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
export interface DeviceSet {
2+
devices: Record<string, string> // deviceHash → ISO timestamp (last seen)
3+
lastAlertedAt?: string // ISO timestamp, for alert suppression
4+
}
5+
6+
/** Remove device entries older than `maxAgeDays` from the current time. */
7+
export function pruneStaleDevices(devices: Record<string, string>, maxAgeDays: number): Record<string, string> {
8+
const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000
9+
const result: Record<string, string> = {}
10+
for (const [hash, timestamp] of Object.entries(devices)) {
11+
if (new Date(timestamp).getTime() >= cutoff) {
12+
result[hash] = timestamp
13+
}
14+
}
15+
return result
16+
}
17+
18+
/** Whether an alert should fire based on device count, threshold, and last alert time. */
19+
export function shouldAlert(deviceCount: number, lastAlertedAt: string | undefined, threshold: number): boolean {
20+
if (deviceCount < threshold) return false
21+
if (!lastAlertedAt) return true
22+
const daysSinceLastAlert = (Date.now() - new Date(lastAlertedAt).getTime()) / (24 * 60 * 60 * 1000)
23+
return daysSinceLastAlert > 30
24+
}

0 commit comments

Comments
 (0)