License System
Desktop only. Web builds use the renderer fallback in
src/lib/license-bridge.tsand stay in a perpetual trial. Key files:
electron/license/manager.cts— state machine + IPC handlerselectron/license/machine-id.cts— 16-character machine codeelectron/license/time-guard.cts— clock rollback defenseelectron/license/storage.cts— three-mirror encrypted storageelectron/license/crypto.cts— Ed25519 / AES-GCM / HKDF / HMACelectron/license/public-key.cts— embedded public key + pepperelectron/license/types.cts— shared typessrc/lib/license-bridge.ts— renderer adaptersrc/lib/editable-guard.ts— edit interception
1. Goals
- Fully offline: no network call. The activation code carries everything required for verification.
- Machine bound: each issued license is valid on exactly one machine; switching hardware requires re-issuance.
- Anti-abuse: detect clock rollback, file tampering, replay, and machine fingerprint drift.
- Graceful degradation: failed validation drops to read-only instead of crashing; the UI surfaces the reason.
- Zero runtime deps: pure Node
crypto, no third-party packages.
2. State machine overview
canEdit: true only in trial and activated. Everything else is read-only.
3. End-to-end flow
4. Token and signature
4.1 Wire format
APMS1.<base64url(payload)>.<base64url(ed25519-sig)>TOKEN_PREFIX = 'APMS1'. Bumping the prefix invalidates every existing token (because parseToken rejects unknown prefixes), giving us emergency key rotation.
4.2 LicensePayload
// electron/license/types.cts:25
interface LicensePayload {
v: 1;
lic: string; // unique per issuance
machine: string; // bound machine code
issued: number; // epoch ms
expires: number; // epoch ms; 0 = perpetual
features?: string[];
name?: string;
nonce: string; // makes two identical-ish licenses produce different bytes
}2
3
4
5
6
7
8
9
10
11
4.3 Verify
// crypto.cts:101-109
export function verifyToken(parsed): boolean {
try {
const sig = fromB64url(parsed.sigB64);
if (sig.length !== 64) return false;
return edVerify(null, Buffer.from(parsed.bodyB64, 'utf8'), getPublicKey(), sig);
} catch {
return false;
}
}2
3
4
5
6
7
8
9
10
The public key is embedded in public-key.cts:
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAc2wnOyeb2Mb5p/byoxXv5WEJfiRMGbI54BCVSWVp63s=
-----END PUBLIC KEY-----2
3
The matching private key lives in tools/license-gen/keys/private.pem and never ships with the app.
5. Machine code — machine-id.cts
5.1 Signal collection
collectSignals() (machine-id.cts:99-117) gathers:
platform/archrelease-major(kernel major version)hostname- first CPU model + core count
totalmemrounded to GiBstableMac(): lexicographically smallest non-internal, non-virtual MAC after filtering Docker / VBox / KVM / VMware OUIsdiskSerial(): Linux/etc/machine-id, macOSIOPlatformUUID, Windowswmic csproduct UUID
5.2 Derivation
ikm = signals.join('||')
digest = HMAC_SHA256(APP_PEPPER, ikm)
code = base32_crockford(digest[0..10]) → "XXXX-XXXX-XXXX-XXXX"2
3
80 bits (16 base32 chars). Collision probability is negligible.
5.3 Persisted hint
userData/.lic-machine.dat stores the first computed code. readPersistedHint() lets LicenseManager detect drift:
// manager.cts:160-163
const persistedHint = readPersistedHint(this.userDataDir);
const machineDrift = persistedHint && persistedHint !== this.machine.code;2
3
Drift → status tampered.
6. TimeGuard — clock defense
time-guard.cts implements three persisted defense signals:
- Monotonic high-water-mark: persisted
lastSeen, ticked every 60 s asmax(now, lastSeen).now < lastSeen - GRACE(5min)→tampered. - Anchor mtime: app binary / package.json install time as a lower bound;
now < anchorMtimeis impossible. - Session counter: decoupled from wallclock, partially limits the "delete userData to reset trial" abuse (recorded but not enforced today).
Forward wallclock jumps do not directly mark tampered: they cannot extend a trial/license, and OS sleep or background suspension can produce the same timer-pause pattern. Real rollback is still blocked by the lastSeen high-water check.
State file userData/.lic-clock.dat is AES-GCM encrypted with an HMAC header.
trustedNow() = max(Date.now(), state.lastSeen): even if the system clock rolls back, license decisions still use the historical maximum.
7. Storage — three-mirror encryption
storage.cts writes three files under userData/:
| File | Content |
|---|---|
license.dat | AES-GCM encrypted PrimaryV1 (token + storedAt + machine) |
.lic-state.json | plaintext JSON: tokenHash + activatedAt + nonce + HMAC |
.lic-shadow.dat | AES-GCM encrypted StateV1 mirror |
Read path cross-checks all three:
if (!safeEqual(computedHash, state.tokenHash)) return tampered('token hash differs');
if (!safeEqual(state.tokenHash, shadow.tokenHash)) return tampered('shadow disagrees with state');
if (!safeEqual(state.machineAtActivation, shadow.machineAtActivation))
return tampered('shadow machine differs');
if (state.activatedAt !== shadow.activatedAt) return tampered('shadow activatedAt differs');
if (!safeEqual(state.mac, expectedMac)) return tampered('state HMAC mismatch');2
3
4
5
6
Any disagreement → the entire license is treated as tampered.
8. KDF — machine code → file keys
// crypto.cts:122-130
function deriveKey(machineCode, info, length = 32) {
const ikm = `${machineCode}|${APP_PEPPER}`;
const salt = sha256(APP_PEPPER);
return hkdfSync('sha256', ikm, salt, info, length);
}2
3
4
5
6
Different info strings give independent sub-keys:
| Use | info |
|---|---|
| AES file enc | apms.license.enc.v1 |
| HMAC mac | apms.license.mac.v1 |
| Per-file key | apms.file.key.v1:<label> |
Different machine code → different keys → license files cannot be copied between machines.
9. Activation flow
ActivationResult.errorCode taxonomy:
| code | trigger |
|---|---|
invalid_format | wrong length / split !== 3 |
invalid_signature | Ed25519 verify failed |
machine_mismatch | payload.machine ≠ this device |
expired | now > expires |
replay | existing license has longer expires |
storage_error | filesystem write failed |
unknown | renderer fallback (web preview) |
10. computeState — single source of truth
manager.cts:156-291 implements the full status decision:
- drift checks (machineDrift / tg.tampered) →
tampered - load storage:
- tampered →
tampered - signature fails →
invalid - machine mismatch →
machine_mismatch now > expires→expired_license- else →
activated
- tampered →
- no license → trial path:
- now < firstSeen →
not_started - now ≥ trialEnd →
expired_trial - else →
trial
- now < firstSeen →
refresh() runs every minute and only broadcast()s when the JSON representation changes.
11. Renderer interception — editableGuard
src/lib/editable-guard.ts:21-36:
export function assertEditable(action = 'edit'): boolean {
const { state, promptActivation } = useLicenseStore.getState();
if (state.canEdit) return true;
if (now - lastWarn > WARN_INTERVAL) {
lastWarn = now;
console.warn(`[license] Blocked ${action}: status=${state.status}. ${state.reason}`);
try {
promptActivation();
} catch {}
}
return false;
}2
3
4
5
6
7
8
9
10
11
12
Call sites:
mapStore.addEntity / updateEntity / removeEntity / reparentEntitycallassertEditablefirst; falsy returns drop the write.useActionDispatchergates every action / tool / selection dispatch onassertEditable.- UI buttons read
isEditable()to render their disabled state.
12. Public API (renderer side)
// src/lib/license-bridge.ts
licenseBridge.getState(): Promise<LicenseState>
licenseBridge.getMachineCode(): Promise<string>
licenseBridge.activate(code): Promise<ActivationResult>
licenseBridge.deactivate(): Promise<LicenseState>
licenseBridge.onChange(handler): () => void2
3
4
5
6
13. Threat model
| Threat | Mitigation |
|---|---|
| Copy license to another machine | Machine code binding + per-machine file keys |
Tamper with expires | Ed25519 signature fails |
| Swap public key | Public key compiled in; ASAR + binary signing on the distribution |
| System clock rollback | TimeGuard lastSeen watermark + 5 min grace |
Replace .lic-state.json | HMAC + shadow cross-check |
| Delete userData to reset trial | Still drops to a fresh trial (design allows; future server throttle) |
| Reverse-engineer the pepper | Pepper is not a secret — only blocks cross-app replay; rotation needs migration |
| Bypass IPC and write files manually | HMAC + AES keys derived from machine code; results in tampered |
14. Tooling
tools/license-gen/:
node tools/license-gen/gen-keys.mjs --rotate # generate / rotate keypair
node tools/license-gen/issue.mjs --machine XXXX-XXXX-XXXX-XXXX --name "Acme" --days 365
node tools/license-gen/verify.mjs --code "APMS1.eyJ...base64..."2
3
The private key stays in tools/license-gen/keys/private.pem (ops only). gen-keys.mjs writes the matching public key back into electron/license/public-key.cts automatically.
15. Pitfalls
- Don't read disk serial after
app.disableHardwareAcceleration()— unrelated, but avoid coupling unrelated subsystems to the license path. - Never pass
LicenseStateto a worker — workers are isolated; the guard runs only in main / renderer entry points. replayonly allows extension: an admin who issues a short expires by mistake can re-issue a longer one; the reverse is rejected.- TimeGuard
tamperedis sticky — once set only a fresh install (or a developer-onlyreset()) clears it. Production builds do not exposereset(). - CI desktop-package job does not sign:
CSC_IDENTITY_AUTO_DISCOVERY: false. Notarisation / code signing is a release-engineer step done locally.
16. Tests
electron/license/__tests__/(planned) should cover:parseToken/verifyTokencontract;- storage round trip and triple-mirror cross-check;
- TimeGuard rollback detection;
- LicenseManager activate / deactivate / replay.
- Renderer side:
license-bridge.test.tsfor the fallback;editable-guard.test.tsfor interception ordering.
17. See also
- Electron Integration
- Build & Bundle
- State Management — licenseStore and editor integration