Deployment note: runtime licensing gates the on-prem/edge clarity binary; the order portal runs on an ExactSpace internet-connected server.
The licensing subsystem spans two codebases: a Python/FastAPI order portal that runs on an internet-connected server, and a Rust module embedded in the Tauri binary that enforces the license entirely offline on the customer's air-gapped machine.
┌─────────────────────────────┐ USB / QR code ┌──────────────────────────────┐
│ Air-Gapped Machine │ │ Order Portal (Cloud) │
│ (Tauri / Rust) │ │ (FastAPI / Python) │
│ │ │ │
│ licensing/ module │ ── challenge ────► │ POST /activate │
│ (7 Rust files) │ │ • validate order │
│ │ ◄── signed JWT ─── │ • sign JWT with priv key │
│ No network calls. │ │ │
│ All enforcement local. │ │ POST /renew │
│ │ │ POST /revoke │
│ │ │ POST /transfer │
└─────────────────────────────┘ └──────────────────────────────┘
The ONLY data crossing the air-gap:
1. Installer build infra → machine (USB)
2. Challenge string machine → portal (USB / QR code)
3. License JWT portal → machine (USB / QR code)
4. Renewal token portal → machine (USB / QR code)
Key algorithm: Ed25519 (EdDSA) — distinct from the API JWT algorithm (HS256). The private key never leaves the portal.
State directory:
~/.clarity/license/%APPDATA%\clarity\license\$TMPDIR/clarity_license_test/clarity:backend/src-tauri/src/licensing/mod.rs:59-76
Source: clarity:order_portal/app/main.py
Start command: uvicorn app.main:app --reload --port 8080
Interactive docs: http://localhost:8080/docs
| Variable | Default | Description |
|---|---|---|
DATABASE_URL |
sqlite:///./orders.db |
SQLAlchemy connection string; supports PostgreSQL |
KEYS_DIR |
./keys/ |
Directory holding private.pem / public.pem |
clarity:order_portal/app/main.py:1-14
orders tableclarity:order_portal/app/database.py:30-45
| Column | Type | Nullable | Description |
|---|---|---|---|
order_id |
TEXT PK | No | UUID — one row per purchase |
customer_id |
TEXT | No | Customer identifier |
status |
TEXT | No | pending \| active \| revoked \| expired \| transferred |
fp_cpu |
TEXT | Yes | NULL until activation, set from challenge |
fp_board |
TEXT | Yes | NULL until activation |
fp_disk |
TEXT | Yes | NULL until activation |
issued_at |
INTEGER | Yes | Unix seconds — set at each signing |
expires_at |
INTEGER | No | Unix seconds — subscription expiry |
tolerance |
INTEGER | No | Hardware match tolerance 1–3 (default 2) |
used_nonces tableclarity:order_portal/app/database.py:48-54
| Column | Type | Nullable | Description |
|---|---|---|---|
nonce_hash |
TEXT PK | No | SHA-256(nonce_bytes) hex — prevents replay |
order_id |
TEXT | No | FK to orders |
used_at |
INTEGER | No | Unix seconds |
No authentication middleware exists in the current codebase. The /revoke and /transfer routes note in their docstrings that an API key or auth middleware should be added before production use.
clarity:order_portal/app/routes/revoke.py:1-10, clarity:order_portal/app/routes/transfer.py:1-11
TODO-VERIFY: Whether auth middleware has been added at the deployment layer (reverse proxy or similar) for production instances. Cannot be determined from source alone — requires inspection of deployment config (nginx/reverse proxy rules, Kubernetes ingress, or similar). The application code itself has no auth on these routes.
POST /orders — Create orderclarity:order_portal/app/routes/orders.py:18-40
Request body (OrderCreate):
{
"order_id": "<UUID string>",
"customer_id": "<string>",
"expires_at": 1234567890,
"tolerance": 2
}
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
order_id |
str | Yes | — | UUID for this purchase |
customer_id |
str | Yes | — | — |
expires_at |
int | Yes | — | Unix seconds |
tolerance |
int | No | 2 |
Must be 1–3 |
Response 201 (OrderResponse):
{
"order_id": "<UUID>",
"customer_id": "<string>",
"status": "pending",
"expires_at": 1234567890,
"tolerance": 2
}
Error: 409 if order_id already exists.
GET /orders/{order_id} — Retrieve orderclarity:order_portal/app/routes/orders.py:43-54
Response 200 (OrderResponse): same shape as above. Error: 404 if not found.
POST /activate — Validate challenge, issue JWTclarity:order_portal/app/routes/activate.py:61-118
Request body (ActivateRequest):
{ "challenge": "<base64url-encoded JSON string>" }
The challenge field is produced by generate_challenge() in the Tauri app:
base64url( JSON({
"order_id": "<UUID>",
"fp_cpu": "<base64url BLAKE3(cpu_id bytes)>",
"fp_board": "<base64url BLAKE3(board_id bytes)>",
"fp_disk": "<base64url BLAKE3(disk_serial bytes)>",
"nonce": "<base64url 32-byte CSPRNG>"
}) )
JSON-in-base64url format is used specifically to avoid binary delimiter ambiguity in Python parsing.
clarity:order_portal/app/routes/activate.py:7-23
Validation sequence (all in a single DB transaction):
order_id, fp_cpu, fp_board, fp_disk, nonce)SELECT orders WHERE order_id = ? — must be status = "pending" (409 if active, revoked, expired)nonce_hash = SHA-256(nonce_bytes) — check against used_nonces (409 if found — replay detected)UPDATE orders SET fp_cpu=?, fp_board=?, fp_disk=?, issued_at=now, status="active" + INSERT INTO used_noncesResponse 200 (ActivateResponse):
{ "token": "<signed Ed25519 JWT>" }
clarity:order_portal/app/routes/activate.py:62-118
POST /renew — Issue renewal JWTclarity:order_portal/app/routes/renew.py:23-69
Request body (RenewRequest):
{
"order_id": "<UUID>",
"old_token": "<existing JWT or null>",
"new_expires_at": 1234567890
}
| Field | Type | Required | Notes |
|---|---|---|---|
order_id |
str | Yes | — |
old_token |
str | null | No | If provided, its Ed25519 signature is verified before issuing; order_id must match |
new_expires_at |
int | Yes | New expiry Unix seconds |
Validation:
status = "revoked"status = "pending" (never activated)status = "transferred" (must activate on new machine first)The renewal JWT carries the same fp_cpu/fp_board/fp_disk as stored in the orders table. The machine does not need to re-activate.
Response 200 (RenewResponse):
{ "token": "<new signed JWT>" }
clarity:order_portal/app/routes/renew.py:23-69
POST /revoke — Admin revocationclarity:order_portal/app/routes/revoke.py:21-32
Request body (RevokeRequest):
{ "order_id": "<UUID>" }
Sets status = "revoked". The machine's active token continues working until its exp date (no online check is possible). Effective revocation on an air-gapped machine requires physical access.
Response 200 (RevokeResponse):
{ "order_id": "<UUID>", "status": "revoked" }
Error: 409 if already revoked.
POST /transfer — Machine transferclarity:order_portal/app/routes/transfer.py:22-46
Request body (TransferRequest):
{ "order_id": "<UUID>" }
Nulls out fp_cpu, fp_board, fp_disk and resets status to "pending". The customer must then run the full activation flow on the new machine.
Response 200 (TransferResponse):
{
"order_id": "<UUID>",
"status": "pending",
"message": "Fingerprints cleared. Customer must now activate on the new machine using the standard activation flow."
}
Errors: 403 if status = "revoked" (cannot transfer); 409 if already "pending".
GET /status/{order_id} — License statusclarity:order_portal/app/routes/status.py:19-33
Response 200 (StatusResponse):
{
"order_id": "<UUID>",
"status": "active",
"expires_at": 1234567890,
"days_remaining": 364,
"tolerance": 2
}
days_remaining = max(0, (expires_at - now) // 86400). Error: 404 if not found.
GET /health — Health checkclarity:order_portal/app/main.py:49-51
Response 200:
{ "status": "ok" }
clarity:order_portal/app/crypto.py
order_portal/
└── keys/
├── private.pem PKCS#8 PEM — must NEVER be committed; stays in portal
└── public.pem SPKI PEM — embedded in Tauri binary at build time
Generate with: python keygen.py --out keys/
sign_license_token(claims) uses PyJWT with algorithm="EdDSA" and the private key.
verify_license_token(token) decodes against the public key with verify_exp=False (expiry enforced separately).
clarity:order_portal/app/crypto.py:50-71
Entry point: clarity:backend/src-tauri/src/licensing/mod.rs
| File | Responsibility |
|---|---|
mod.rs |
Module root: LicenseError enum, is_test_mode(), license_data_dir(), build constants include! |
fingerprint.rs |
Hardware reads, BLAKE3 hashing, FingerprintComponents, fp_matches() |
jwt.rs |
LicenseClaims struct, verify_token() (EdDSA), sign_token() (test/CLI only) |
keystore.rs |
Encrypted-file-only token + order_id storage (no OS keychain). AES-256-GCM + HMAC-SHA256; keys bound to hardware fingerprint + order_id + JWT_SECRET; in-memory TOKEN_CACHE |
last_seen.rs |
AES-256-GCM encrypted + HMAC-SHA256 clock-rollback defence file |
nvram.rs |
AES-256-GCM encrypted + HMAC-SHA256 anti-rollback counter file |
activation.rs |
generate_challenge(), activate(), get_order_id() |
runtime.rs |
verify_license(), on_clean_exit(), start_background_tasks(), brick_license() |
lic_log.rs |
lic_info! / lic_warn! / lic_error! macros (target clarity::licensing) — route licensing logs to licensing.log |
lic_log.rs)A new lic_log module provides lic_info!/lic_warn!/lic_error! macros that log at target "clarity::licensing". The main.rs log consumer routes that target to a dedicated licensing.log (100 MB rotation). Logging is now extensive across activate(), keystore, and run_checks() — but by rule never logs the order_id string, JWT token, fingerprint hashes, or key material; only timestamps, counter values, file paths, error types, and pass/fail. clarity:backend/src-tauri/src/licensing/lic_log.rs:1-30, clarity:backend/src-tauri/src/main.rs:2390-2441
Events occurring before the logger is initialized (the single-instance lock check) are written directly to licensing.log via write_licensing_log_direct() in main.rs. The logger is now initialized before the license check so the check itself is captured. clarity:backend/src-tauri/src/main.rs:1593-1620
clarity:backend/src-tauri/src/licensing/mod.rs:16-20
build.rs reads three build-time env vars and generates OUT_DIR/license_constants.rs, which is include!-ed at compile time:
| Constant | Source env var | Purpose |
|---|---|---|
EMBEDDED_ORDER_ID |
CLARITY_ORDER_ID |
UUID tying binary to one order |
EMBEDDED_PUB_KEY_PEM |
CLARITY_PUB_KEY_PATH |
Public key for offline JWT verification |
EMBEDDED_MANIFEST_JSON |
(generated by build.rs) | {"order_id": "...", "build_ts": <unix>} |
EMBEDDED_MANIFEST_SIG |
CLARITY_PRIV_KEY_PATH |
Ed25519 signature of manifest — proves binary integrity |
The private key signs only the build manifest and is never embedded. In dev builds all constants are empty and the runtime falls back to env vars.
Source: clarity:backend/src-tauri/src/licensing/fingerprint.rs
Three hardware identifiers are read and individually hashed with BLAKE3:
| Component | Linux | macOS | Windows |
|---|---|---|---|
| CPU ID | /sys/class/dmi/id/product_serial only (the low-entropy /proc/cpuinfo model-name fallback was removed in c41a03b; fingerprint.rs:171-180) |
IOPlatformSerialNumber (was sysctl -n hw.cpufamily, a CPU-generation constant — changed in c41a03b; ioreg/diskutil by absolute /usr/sbin/… path, fingerprint.rs:212-215) |
Win32_Processor.ProcessorId → fallback Win32_ComputerSystemProduct.UUID |
| Board ID | product_uuid → board_serial → /etc/machine-id → /var/lib/dbus/machine-id (fingerprint.rs:188-193) |
ioreg -rd1 -c IOPlatformExpertDevice → IOPlatformUUID |
Win32_BaseBoard.SerialNumber → fallback Win32_ComputerSystemProduct.UUID |
| Disk serial | /sys/block/nvme0n1/device/serial → sda → sdb → vda |
diskutil info / → Volume UUID |
Win32_DiskDrive.SerialNumber |
Anchor-quality hardening (c41a03b, commit ca3cb27). is_placeholder() (fingerprint.rs:54-78) rejects empty/unknown-*/OEM-junk values ("None", "Default string", "To be filled by O.E.M.", all-0/all-F UUIDs); real_anchor_count() (:40-48) counts distinct real anchors (identical collapsed values dedup to one); fp_matches() (:127-152) skips placeholders, dedups collapsed anchors, and clamps with tolerance.min(3). At activation, activate() refuses with InsufficientEntropy when real_anchors < claims.tolerance (activation.rs:72-79) — a box whose only anchor is a machine-id can no longer activate a tolerance-2 license.
Windows fingerprinting uses a three-tier WMI fallback (
a18d35chardened the prior single-wmicpath so it survives Windows 11 builds that have removedwmic).wmi_field(class, field)tries, in order: (1)C:\Windows\System32\wbem\wmic.exe <alias> get <field> /value(absolute path), (2) PowerShellGet-CimInstance -ClassName <class>, (3) thewmiRust crate over direct COM (SELECT <field> FROM <class>).is_valid_hw_value()rejects blanks and known junk (None,"To be filled by O.E.M.", the all-FUUID). If every tier fails it still falls back to the"unknown-windows-*"placeholders, so the build never breaks.clarity:backend/src-tauri/src/licensing/fingerprint.rs:205-320
clarity:backend/src-tauri/src/licensing/fingerprint.rs:93-320
Each identifier is converted to bytes and hashed:
cpu_id_bytes ──► BLAKE3 ──► base64url(URL_SAFE_NO_PAD) ──► fp_cpu
board_id_bytes──► BLAKE3 ──► base64url ──► fp_board
disk_id_bytes ──► BLAKE3 ──► base64url ──► fp_disk
cpu || board || disk ──► BLAKE3 ──► combined_hash [u8; 32]
(used for AES-GCM key derivation — NOT in JWT)
clarity:backend/src-tauri/src/licensing/fingerprint.rs:12-36
fp_matches() function:
clarity:backend/src-tauri/src/licensing/fingerprint.rs:127-152
fp_matches(components, fp_cpu, fp_board, fp_disk, tolerance)
→ (passed: bool, matched_count: u8)
// passed = matched_count >= tolerance.min(3)
| Tolerance value | Meaning |
|---|---|
3 |
All three must match |
2 (default) |
Any two of three must match; tolerates one hardware replacement |
1 |
Any one must match (not recommended) |
Tolerance is baked into the JWT by the portal at activation time and cannot be changed without a new portal-signed token.
Test mode hardware reads:
clarity:backend/src-tauri/src/licensing/fingerprint.rs:48-60
When CLARITY_LICENSE_TEST_MODE=1, hardware reads are replaced by env vars:
CLARITY_TEST_CPU (default: "clarity-test-cpu-id-0001")CLARITY_TEST_BOARD (default: "clarity-test-board-id-0001")CLARITY_TEST_DISK (default: "clarity-test-disk-id-0001")LicenseClaims)Source: clarity:backend/src-tauri/src/licensing/jwt.rs:12-31
{
"alg": "EdDSA",
"typ": "JWT"
}
| Field | Rust type | Description |
|---|---|---|
order_id |
String |
UUID of the purchase order |
fp_cpu |
String |
base64url(BLAKE3(cpu_id_bytes)) |
fp_board |
String |
base64url(BLAKE3(board_id_bytes)) |
fp_disk |
String |
base64url(BLAKE3(disk_serial_bytes)) |
iat |
u64 |
Issued-at (Unix seconds) |
exp |
u64 |
Expiry (Unix seconds) |
nonce |
String |
base64url(32-byte CSPRNG) — prevents challenge replay; empty string on renewal tokens |
version |
u32 |
Schema version — currently 1 |
tolerance |
u8 |
Hardware match tolerance 1–3 |
Rationale for three separate fp hashes: A single combined hash makes 2-of-3 tolerance comparison impossible — you cannot tell which components matched. Three separate hashes allow fp_matches() to count exact matches.
clarity:backend/src-tauri/src/licensing/jwt.rs:6-11
Verification: Algorithm::EdDSA via jsonwebtoken::decode. validation.validate_exp = false — expiry is checked against last_seen_ts, not the live system clock.
clarity:backend/src-tauri/src/licensing/jwt.rs:41-51
keystore.rs)Source: clarity:backend/src-tauri/src/licensing/keystore.rs
Changed in
dab6060— the OS keychain was removed entirely. Earlier revisions used thekeyringcrate (Windows Credential Manager / macOS Keychain / Linux SecretService) as the default store with an encrypted-file fallback. All of that —use_file_keystore(), the SecretService probe,CLARITY_LICENSE_FILE_KEYSTORE, thekeyring-backed code paths, and thePlatformFailure/NoStorageAccess→KeystoreUnavailablemapping inload_token— is gone. Licensing now uses encrypted files only, on every platform and in every mode. (As of1027dcethekeyringcrate is removed fromCargo.tomlentirely —secure_store.rs, the last remaining user, was also migrated to the same encrypted-file scheme. See API Server § Seed credentials & secret pack.)
All license state lives in two AES-256-GCM + HMAC-SHA256 encrypted files under {license_data_dir()}:
| File | Holds | Key derivation |
|---|---|---|
order_id.bin |
The order ID | BLAKE3.derive_key(ctx, hardware_fp ‖ JWT_SECRET) — bound to this machine only |
token.bin |
The JWT license token | BLAKE3.derive_key(ctx, order_id ‖ hardware_fp ‖ JWT_SECRET) — bound to this machine AND this purchase |
clarity:backend/src-tauri/src/licensing/keystore.rs:48-82
File format (both files, variable length):
[32 bytes] HMAC-SHA256 of ciphertext (tamper detection)
[12 bytes] AES-256-GCM nonce (random per write)
[N bytes] AES-256-GCM ciphertext + 16-byte auth tag
clarity:backend/src-tauri/src/licensing/keystore.rs:86-123
Key material — three bindings. Both files mix JWT_SECRET (the build-time HS256 secret from auth.rs, baked into the binary) into the BLAKE3 key material, on top of the hardware fingerprint and (for the token) the order ID. An attacker must therefore reverse-engineer the binary to recover JWT_SECRET and read the machine's hardware IDs and know the order ID before either file can be decrypted. Each file/key-type uses a distinct BLAKE3 context string (clarity license token aes-256-gcm 2024, clarity license token hmac-sha256 2024, clarity order_id aes-256-gcm 2024, clarity order_id hmac-sha256 2024) so the four derived keys are cryptographically independent. clarity:backend/src-tauri/src/licensing/keystore.rs:51-82
File permissions. write_protected() sets 0o600 (owner read/write only) on Linux/macOS. On Windows, %APPDATA% is NTFS-restricted to the current user by default. clarity:backend/src-tauri/src/licensing/keystore.rs:127-138
Two-step unlock on load. load_token() cannot decrypt token.bin directly — the token key needs the order ID, which is itself encrypted:
hardware_fp ‖ JWT_SECRET → decrypt order_id.bin → order_idorder_id ‖ hardware_fp ‖ JWT_SECRET → decrypt token.bin → JWTIf order_id.bin is missing or undecryptable, load_token returns NotActivated without touching token.bin. clarity:backend/src-tauri/src/licensing/keystore.rs:214-254
Write ordering at activation. store_order_id() must run before store_token() (the token key derives from the order ID). activate() calls them in that order. clarity:backend/src-tauri/src/licensing/activation.rs:76-84
In-memory token cache. TOKEN_CACHE: Mutex<Option<String>> is populated on every store_token and on the first successful load_token, and cleared by delete_token. load_token returns the cached value first, so the hourly re-check never re-reads or re-decrypts the files. clarity:backend/src-tauri/src/licensing/keystore.rs:44, 198-221, 251-259
Plaintext migration. load_order_id() first attempts the encrypted format; on decrypt failure it falls back to reading order_id.bin as plaintext (legacy CLARITY_LICENSE_TEST_MODE installs that wrote the bare string), and if non-empty, re-stores it in encrypted form and returns it. clarity:backend/src-tauri/src/licensing/keystore.rs:165-192
CONFLICT with old LICENSING_LLD.md §6: The old doc states test-mode stores
token.txt(plain text). The actual code storestoken.binAES-256-GCM-encrypted. The source is authoritative.
Source: clarity:backend/src-tauri/src/licensing/runtime.rs:127-185
verify_license() runs four checks in order, cheapest first:
Check 0: Build manifest signature
EMBEDDED_MANIFEST_SIG verified against EMBEDDED_MANIFEST_JSON
using EMBEDDED_PUB_KEY_PEM (Ed25519 / ed25519-dalek)
→ Detects post-build binary tampering
Skipped if EMBEDDED_MANIFEST_JSON is empty (dev build)
Check 1: JWT signature
keystore::load_token()
jwt::verify_token(token, EMBEDDED_PUB_KEY_PEM)
→ Rejects forged tokens, wrong-order tokens, tampered payloads
Check 2: Expiry + clock rollback
fp = fingerprint::collect()
last_seen_ts = last_seen::read(order_id, combined_fp)
if last_seen_ts > sys_now → ClockRollback
if last_seen_ts + 300 < claims.iat → ClockRollback (backdated at activation)
if claims.exp < last_seen_ts → Expired
Expiry is NEVER checked against the live system clock.
> The iat lower-bound now allows a 300 s (5-minute) grace
> (`last_seen_ts + 300 < iat`) to tolerate minor clock skew between
> the portal and the machine at activation time. Previously any
> last_seen earlier than iat tripped ClockRollback.
Check 3: Fingerprint 2-of-3
fp_matches(current_fp, claims.fp_*, claims.tolerance)
→ Catches copied installer+token on different machine
Check 4: NVRAM counter integrity
nvram::read_counter(order_id)
→ Verifies HMAC-SHA256 integrity tag; returns Err if tampered
I/O cost ordering:
| Check | I/O required | Rationale |
|---|---|---|
| 0. Manifest sig | Memory only | Pure Ed25519 computation |
| 1. JWT sig | 2 file reads + AES-GCM decrypt (order_id.bin, token.bin); cached after first load |
Two-step unlock — see Token Storage |
| 2. Expiry | File read + AES-GCM decrypt | Needs fp key derived from claims |
| 3. Fingerprint | Multiple hardware syscalls | Platform-specific reads |
| 4. NVRAM | File read | Simple, last |
Source: clarity:backend/src-tauri/src/licensing/runtime.rs:37-57, 189-194
On any check failure (except NotActivated): brick_license() is called immediately — no grace period.
fn brick_license() {
keystore::delete_token(); // token.bin deleted
keystore::delete_order_id(); // order_id.bin deleted
last_seen::delete(); // last_seen file zeroed then deleted
nvram::delete(); // NVRAM counter file deleted
}
NotActivated is the only exception — no state exists to wipe; the app shows the activation screen.
Three special cases that do NOT brick:
LicenseError::NotActivated — return error, show activation UILicenseError::KeystoreUnavailable — verify_license() and the hourly check still carry an arm for this (skip the cycle, do not brick), but with the file-only keystore (dab6060) load_token no longer produces it — the variant was tied to the removed OS credential store. The arm now only matters as defensive code; the transient-recovery role it played is taken over by the new NotActivated streak tolerance below. clarity:backend/src-tauri/src/licensing/runtime.rs:49-57, 141-148LicenseError::NotActivated (hourly check only) — tolerated up to twice in a row. A consecutive-NotActivated counter lets the hourly re-check survive up to 2 consecutive NotActivated results, bricking only on the 3rd (streak >= 3). A success or KeystoreUnavailable resets the streak to 0. This protects against a transient miss (e.g. the process restarted with an empty token cache and a momentary file-read hiccup) permanently destroying a valid license. The launch-time verify_license() (above) still returns NotActivated immediately — the streak applies only to the background thread. clarity:backend/src-tauri/src/licensing/runtime.rs:126-179CLARITY_LICENSE_PUB_KEY_PATH env var — warn, return error, do NOT wipeRationale for no grace period: The threat model assumes legitimate hardware failures are handled via POST /transfer. Clock corrections large enough to trigger ClockRollback should not occur on industrial NTP-stabilised servers. Any check failure in a running factory is a support event warranting escalation.
clarity:backend/src-tauri/src/licensing/runtime.rs:5-20
last_seen.rs)Source: clarity:backend/src-tauri/src/licensing/last_seen.rs
Goal: Make the system clock irrelevant to expiry enforcement.
Mechanism: On every clean exit and every hour via background task, the current Unix timestamp is encrypted and written to {license_data_dir()}/last_seen.bin. Expiry is checked against this stored value, not the live clock. Rolling the clock back does not help — the stored timestamp will be in the future relative to the rolled-back clock, which triggers ClockRollback.
File format (68 bytes total):
clarity:backend/src-tauri/src/licensing/last_seen.rs:6-18
Offset Size Content
0 32 HMAC-SHA256 of the decrypted 8-byte plaintext timestamp
32 12 AES-256-GCM nonce (random per write)
44 24 AES-256-GCM ciphertext (8-byte u64 LE + 16-byte auth tag)
Total: 32 + 12 + 24 = 68 bytes.
Key derivation:
clarity:backend/src-tauri/src/licensing/last_seen.rs:32-55
material = order_id_bytes || combined_fp_hash_32_bytes
aes_key = BLAKE3.derive_key("clarity license last-seen aes-256-gcm 2024", material)
hmac_key = BLAKE3.derive_key("clarity license last-seen hmac-sha256 2024", material)
Two distinct domain strings produce cryptographically independent keys from the same material. Copying the file to a different machine or order produces a different key → decryption fails.
Read verification order: decrypt first → then verify HMAC over decrypted plaintext.
clarity:backend/src-tauri/src/licensing/last_seen.rs:93-135
run_checks() clock rules (c41a03b): fail ClockRollback if last_seen_ts > sys_now or last_seen_ts + 300 < claims.iat; fail Expired if claims.exp < last_seen_ts (runtime.rs:220-231). now_secs() returns 0 instead of panicking on a pre-1970 clock (mod.rs:95-100).
Signed build manifest (c41a03b): verify_build_manifest() (runtime.rs:279-348) now treats an empty manifest with an embedded pubkey as ManifestTampered in release builds (:296-300) and binds the manifest to the embedded key via pubkey_b3 = blake3(pub_key_pem) (:338-346); build.rs produces {"build_ts":…, "pubkey_b3":"…"}.
Docs note: the new
docs/user/HEADLESS_LINUX_LICENSING.mdaccurately describes the file-only keystore and Linux anchors; one stale point — it says a status command is "not yet implemented", but alicensing_statusTauri command exists (main.rs:2028-2035, returnsactive/not_activated/error:…); only a CLI subcommand is missing. Someruntime.rscomments still say "OS keychain" (cosmetic — storage is file-only).
nvram.rs) — rewritten in c41a03bSource: clarity:backend/src-tauri/src/licensing/nvram.rs
Goal: Detect VM snapshot restore and disk cloning.
c41a03b redesign — mirrored monotonic high-water mark. The previous "increment counter" file was written but never compared (per the module header, nvram.rs:1-18); it is replaced by a timestamp high-water mark mirrored across multiple guard directories — the license dir plus ~/.config/clarity/guard/ (Linux/macOS) or %LOCALAPPDATA%\clarity\guard\ (Windows) (guard_dirs(), mod.rs:113-137). read_hwm takes the max across mirrors (nvram.rs:132-160); check() fails with RollbackDetected when reference_ts + ROLLBACK_SLACK_SECS < hwm, with ROLLBACK_SLACK_SECS = 6*3600 (6 h slack; nvram.rs:54,189-195). Restoring a VM snapshot rolls the reference timestamp behind the surviving mirror's high-water mark and trips the check. The pre-c41a03b counter-lifecycle description below is retained for historical context; the increment/reset counter semantics no longer apply.
File format (68 bytes total):
clarity:backend/src-tauri/src/licensing/nvram.rs:5-21
Offset Size Content
0 32 HMAC-SHA256 of ciphertext
32 12 AES-256-GCM nonce (random per write)
44 24 AES-256-GCM ciphertext (8-byte u64 counter LE + 16-byte auth tag)
CONFLICT with old LICENSING_LLD.md §9: The old doc describes a 40-byte format (8-byte counter + 32-byte BLAKE3 keyed-hash) with key domain
"clarity license nvram counter 2024"keyed only onorder_id. The actual code uses a 68-byte AES-256-GCM + HMAC-SHA256 format, identical tolast_seen.bin, with key materialorder_id_bytes || combined_fp_hash(hardware-bound). The code's own comment explains the change: "The old design keyed only on order_id, meaning the counter file could be copied between machines with the same order." The source is authoritative.
Key derivation:
clarity:backend/src-tauri/src/licensing/nvram.rs:34-49
material = order_id_bytes || combined_fp_hash_32_bytes
aes_key = BLAKE3.derive_key("clarity license nvram aes-256-gcm 2024", material)
hmac_key = BLAKE3.derive_key("clarity license nvram hmac-sha256 2024", material)
Counter lifecycle:
| Event | Action |
|---|---|
| First activation | reset() → counter = 0 |
| Machine transfer | reset() → counter = 0 |
Clean exit (on_clean_exit) |
increment() |
| Hourly background save | No counter change |
| Brick | delete() |
clarity:backend/src-tauri/src/licensing/nvram.rs:128-147
Source: clarity:backend/src-tauri/src/licensing/runtime.rs:88-145
start_background_tasks(verified_order_id, on_brick) is called once after a successful launch verification. It spawns two std::thread::spawn threads:
Thread 1 (every 3600 s):
last_seen::write(now, order_id, fp) ← write current timestamp
Purpose: industrial app may run for months without a clean exit
Thread 2 (every 3630 s — 30 s after Thread 1):
verify_license()
on failure: on_brick(error_message) + break loop
(NotActivated tolerated up to 2× in a row — see Immediate Brick Policy)
Purpose: catch expired / revoked licenses while app is running
Two changes in this revision:
verified_order_id parameter (was none). The caller must pass claims.order_id from the successful launch verification. The hourly save uses this captured value instead of get_order_id(), which can silently fall back to "dev-order-000" if Credential Manager is momentarily unavailable — that would encrypt last_seen with the wrong key and break verification an hour later. The old standalone save_last_seen_now() helper was removed; the write is now inlined in Thread 1. clarity:backend/src-tauri/src/licensing/runtime.rs:88-135last_seen.on_brick is provided by main.rs and emits a Tauri event + causes app exit or lock screen. It is invoked as start_background_tasks(claims.order_id.clone(), move |err| app_handle.exit(1)). clarity:backend/src-tauri/src/main.rs:1933-1945
clarity:backend/src-tauri/src/main.rs:1417-1457
These commands are exposed to the Tauri frontend:
| Command | Function | Notes |
|---|---|---|
licensing_generate_challenge |
licensing::activation::generate_challenge() |
Called on first launch, no token |
licensing_activate |
licensing::activation::activate(token) |
Returns Result<String, String> (order_id on success) |
licensing_status |
licensing::runtime::verify_license() |
Returns "ok", "not_activated", or error string |
licensing_config_embedded |
Returns JSON — whether constants are embedded | Used by UI to detect dev vs production build |
licensing_set_config |
Sets CLARITY_ORDER_ID env var for dev builds |
|
licensing_restart |
app_handle.restart() |
Called after successful activation to reload |
CLI subcommands (handled in main() before any Tauri/GUI init, so they work on headless servers):
| Command | Behaviour |
|---|---|
clarity generate-challenge |
Prints the hardware fingerprint challenge string to stdout and exits. Works on headless Linux VMs — no display or SecretService required. clarity:backend/src-tauri/src/main.rs:1700-1714 |
clarity activate <token> |
Verifies the token against this machine's fingerprint and stores it as the AES-256-GCM encrypted order_id.bin + token.bin (the only storage mode — no keychain/SecretService on any platform). Token may be an argument or piped via stdin. Result written to clarity_activate.log. clarity:backend/src-tauri/src/main.rs:1716-1752 |
clarity:docs/_archive/developer/LICENSING_LLD.md §12 (confirmed against source)
1. PURCHASE
Billing → POST /orders { order_id, customer_id, expires_at, tolerance }
Portal: status = "pending"
2. BUILD (per-order CI/CD)
CLARITY_ORDER_ID=<uuid>
CLARITY_PUB_KEY_PATH=order_portal/keys/public.pem
CLARITY_PRIV_KEY_PATH=order_portal/keys/private.pem
cargo build
→ build.rs embeds ORDER_ID, PUB_KEY_PEM, MANIFEST_SIG
3. DELIVERY
Installer → customer via USB
4. FIRST LAUNCH
App: generate_challenge() → text + QR code
Customer: USB/QR to internet device → POST /activate { challenge } → JWT
Customer: USB/QR back → app: activate(JWT) or CLI: clarity.exe activate <token>
App stores: order_id.bin (encrypted), then token.bin (encrypted), counter=0, last_seen=now
5. EVERY LAUNCH
verify_license() — 4 checks
On any fail → brick_license() → lock screen / exit
On pass → start_background_tasks(claims.order_id, on_brick)
6. EVERY HOUR (background threads)
Thread 1 (3600 s): last_seen::write(now, order_id, fp)
Thread 2 (3630 s): verify_license()
fail → on_brick() → app exit / lock screen
7. RENEWAL (annual)
Customer: POST /renew { order_id, new_expires_at } → new JWT
Customer: USB to machine → activate(new_JWT) (same machine, no new challenge needed)
8. MACHINE TRANSFER
Admin: POST /transfer { order_id } → fp nulled, status="pending"
Customer: full activation flow (new challenge) on new machine
9. REVOCATION
Admin: POST /revoke { order_id } → status="revoked"
Current token valid until exp (offline limitation)
Next renewal attempt → 403
clarity:docs/_archive/developer/LICENSING_LLD.md §13 (confirmed against source)
| Attack | Defence | Result |
|---|---|---|
| Copy installer + token to second machine | fp_matches() fails (check 3) |
Brick on first launch |
Copy token.bin / order_id.bin to second machine |
Files encrypted with a key bound to hardware fp + JWT_SECRET; HMAC/AES-GCM decrypt fails on different hardware |
load_token → NotActivated (no token to verify) |
| Clone full disk to second machine | New machine has different hardware fp | Brick on first launch |
| VM snapshot restore | last_seen_ts > sys_now (check 2) or NVRAM file missing |
Brick on next launch |
| Roll back system clock | last_seen_ts > sys_now (check 2) |
Brick immediately |
Tamper last_seen.bin |
HMAC-SHA256 verification fails → LastSeenCorrupt |
Brick immediately |
Tamper nvram_counter.bin |
HMAC-SHA256 verification fails | Brick immediately |
| Forge a JWT | Ed25519 sig fails without private key (check 1) | Brick immediately |
| Replay activation challenge | Nonce already in used_nonces DB |
Portal rejects (409) |
| Activate same order on two machines | Second activation: status = "active" → 409 |
Portal rejects |
| Modify counter file to rewind | HMAC-SHA256 integrity tag fails | Brick immediately |
| Post-build binary tampering | Build manifest sig mismatch (check 0, Ed25519) | Brick immediately |
Honest limitations: A root-access attacker who can patch the binary can remove the checks. Recommended hardening: code-sign the binary (Windows Authenticode / Linux IMA), enable Secure Boot, monitor portal for unusual fp_hash patterns.
| Variable | Required | Description |
|---|---|---|
CLARITY_ORDER_ID |
Production | UUID for this customer's order |
CLARITY_PUB_KEY_PATH |
Production | Path to keys/public.pem |
CLARITY_PRIV_KEY_PATH |
Production | Path to keys/private.pem — signs build manifest only, never embedded |
| Variable | Description |
|---|---|
CLARITY_LICENSE_TEST_MODE=1 |
Enable test mode: mock hardware reads (see CLARITY_TEST_*), $TMPDIR state dir. As of dab6060 it no longer affects the keystore — token/order_id are always encrypted files. |
CLARITY_LICENSE_PUB_KEY_PATH |
Path to public key PEM (dev builds without embedded key) |
CLARITY_ORDER_ID |
Order ID override (dev builds or CLI activation) |
CLARITY_TEST_CPU |
Mock CPU identifier string (default: "clarity-test-cpu-id-0001") |
CLARITY_TEST_BOARD |
Mock board identifier string (default: "clarity-test-board-id-0001") |
CLARITY_TEST_DISK |
Mock disk identifier string (default: "clarity-test-disk-id-0001") |
CLARITY_ACTIVATE_TOKEN |
Token for the activate_with_portal_token integration test |
| Variable | Default | Description |
|---|---|---|
DATABASE_URL |
sqlite:///./orders.db |
SQLAlchemy connection string |
KEYS_DIR |
./keys/ |
Directory holding private.pem / public.pem |
clarity:backend/src-tauri/src/licensing/mod.rs:48-53, clarity:order_portal/app/main.py:1-14, clarity:order_portal/app/crypto.py:35
clarity:order_portal/ directory confirmed by ls.
| File | Responsibility |
|---|---|
keygen.py |
One-time Ed25519 keypair generation |
app/main.py |
FastAPI app entry, lifespan (DB table creation), route registration |
app/database.py |
SQLAlchemy models (Order, UsedNonce), get_db() dependency |
app/crypto.py |
sign_license_token(), verify_license_token(), key file loading |
app/schemas.py |
Pydantic request/response models for all 7 endpoints |
app/routes/orders.py |
POST /orders, GET /orders/{id} |
app/routes/activate.py |
POST /activate — challenge decode, nonce replay check, JWT issuance |
app/routes/renew.py |
POST /renew — renewal JWT with updated expiry |
app/routes/revoke.py |
POST /revoke — admin revocation |
app/routes/transfer.py |
POST /transfer — machine transfer, fp reset |
app/routes/status.py |
GET /status/{order_id} — status + days remaining |
From clarity:docs/_archive/developer/LICENSING_LLD.md §16 — not yet verified as implemented:
| Item | Priority |
|---|---|
| UEFI NVRAM counter (replace file fallback) | High |
"unknown-windows-*" placeholders)a18d35c to a three-tier wmic.exe → PowerShell → wmi crate fallback (see Machine Fingerprinting) |
|
build.rs integration in per-order CI/CD pipeline |
High |
API key / auth middleware for /revoke and /transfer portal endpoints |
Medium |
| Multi-seat orders (current design: 1 order = 1 machine) | Low |
| Binary obfuscation for license verification code | Low |
| Portal activation / renewal / revoke audit log | Low |
Last updated: 2026-07-12 from clarity@c41a03b