license/manager.cts — main-process license state machine
Source:
electron/license/manager.cts· 327 lines
Purpose
LicenseManager is the main-process single source of truth for licensing. It owns:
- Machine fingerprint (delegates to
machine-id.cts) - Time guard (delegates to
time-guard.cts) — clock rollback / drift / mtime anchor checks - Encrypted storage (delegates to
storage.cts) — three-mirror + HMAC - Signature verification (delegates to
crypto.cts) — Ed25519 detached signature - IPC surface — four invoke handlers + one push channel
- Periodic refresh — recomputes state every 60 s and broadcasts on change
The renderer never sees raw tokens or private state — only a sanitised LicenseState plus the boolean canEdit.
Public API
| Symbol | Kind | Summary |
|---|---|---|
LicenseManager | class | The main class |
LICENSE_IPC | const | Four IPC channel strings |
STATUS_BROADCAST_CHANNEL | const | 'license:state' push channel |
| Type re-exports | type | LicenseState, ActivationResult, LicenseStatus |
LICENSE_IPC constants
export const LICENSE_IPC = {
GET_STATE: 'license:get-state',
GET_MACHINE_CODE: 'license:get-machine-code',
ACTIVATE: 'license:activate',
DEACTIVATE: 'license:deactivate',
} as const;2
3
4
5
6
Must stay in sync with the same constants in electron/preload.cts.
Class members
| Member | Signature | Summary |
|---|---|---|
constructor() | () | Computes the machine code, builds storage / time guard |
start() | (): void | Starts the time guard, registers IPC handlers, starts the 60 s timer |
stop() | (): void | Clears the timer, stops the time guard (persisting state) |
getState() | (): LicenseState | Returns the cached state (no IO) |
getMachineCode() | (): string | Returns the machine code |
Private methods: activate / deactivate / refresh / computeState / broadcast / failedActivation.
Detailed behaviour
Constructor
constructor() {
this.userDataDir = app.getPath('userData');
this.machine = computeMachineCode(this.userDataDir);
const anchorPaths = [
app.getAppPath(),
path.join(app.getAppPath(), 'package.json'),
process.execPath,
].filter((p) => existsSync(p));
this.timeGuard = new TimeGuard(this.userDataDir, this.machine.code, anchorPaths);
this.storage = new LicenseStorage(this.userDataDir, this.machine.code);
this.cachedState = this.computeState();
}2
3
4
5
6
7
8
9
10
11
12
13
14
Notes:
userDatais Electron's recommended persisted directory (%APPDATA%/~/Library/Application Support/~/.config).anchorPathsare fed toTimeGuard— Electron binary /package.jsonmtimes act as monotonic anchors (system clock < anchor mtime → tampering).computeStateis run at construction so the renderer's firstgetStatereturns the cached value.
start()
start(): void {
this.timeGuard.start();
this.cachedState = this.computeState();
// 60 s self-refresh — banner countdown ticks without renderer polling
this.rebroadcastTimer = setInterval(() => this.refresh(), 60 * 1000);
if (typeof this.rebroadcastTimer.unref === 'function') this.rebroadcastTimer.unref();
ipcMain.handle(LICENSE_IPC.GET_STATE, () => this.refresh());
ipcMain.handle(LICENSE_IPC.GET_MACHINE_CODE, () => this.machine.code);
ipcMain.handle(LICENSE_IPC.ACTIVATE, (_e, code: unknown) => this.activate(code));
ipcMain.handle(LICENSE_IPC.DEACTIVATE, () => this.deactivate());
}2
3
4
5
6
7
8
9
10
11
12
13
unref() lets the timer not block process exit; environments without unref (Bun, very old Node) silently skip.
activate(code) — flow
Eleven steps, fail-fast at any:
- Format —
codeis a string, length in (0, 4096]. - Decode —
parseToken(code)returns{ payload, bodyB64, sigB64 }or null. - Signature —
verifyToken(parsed)against the embedded Ed25519 public key. - Machine match —
safeEqual(payload.machine, this.machine.code)(constant-time). - Expiry —
payload.expires > 0 && trustedNow() > payload.expires. - Replay protection — same
licid already on disk and the new token'sexpiresis older → reject (downgrade). - Persist —
storage.save(cleanToken, payload); IO failure →storage_error. - Recompute —
cachedState = computeState(). - Broadcast —
broadcast(). - Return
{ ok: true, state }.
Any failure returns:
return this.failedActivation('invalid_signature', 'Signature does not match.');deactivate()
private deactivate(): LicenseState {
this.storage.clear();
this.cachedState = this.computeState();
this.broadcast();
return this.cachedState;
}2
3
4
5
6
Removes the three mirror files; the state falls back to trial or expired_trial based on the time-guard window.
computeState() — the heart
Decided by priority (first match wins):
tampered— time guard tampered or persisted machine hint differs from current.- Stored license loaded:
tamperedfrom storage's three-mirror cross-check.- Re-verify token (
parseToken + verifyToken) defensively →invalidon failure. - Re-check machine match →
machine_mismatch. expires > 0 && now > expires→expired_license(canEdit=false).- Otherwise →
activated(canEdit=true).
- Trial path:
now < trialStart→not_started(clock skew forward).now >= trialEnd→expired_trial.- Otherwise →
trial(canEdit=true,daysRemainingcountdown).
trustedNow is max(Date.now(), lastSeen) from the TimeGuard.
refresh()
private refresh(): LicenseState {
const next = this.computeState();
const changed = JSON.stringify(next) !== JSON.stringify(this.cachedState);
this.cachedState = next;
if (changed) this.broadcast();
return this.cachedState;
}2
3
4
5
6
7
JSON-stringify equality is good enough — state is < 1 KB.
broadcast()
private broadcast(): void {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
win.webContents.send(STATUS_BROADCAST_CHANNEL, this.cachedState);
}
}
}2
3
4
5
6
7
Pushes to every live BrowserWindow (multi-window builds, About window, etc.).
summariseLicense(p) — payload sanitisation
function summariseLicense(p: LicensePayload): NonNullable<LicenseState['license']> {
return {
id: p.lic,
name: p.name ?? '',
issued: p.issued,
expires: p.expires,
};
}2
3
4
5
6
7
8
Exposes only four fields. machine, features, nonce, v never reach the renderer.
Sequence diagrams
Activation
60-second heartbeat
Security model
| Threat | Defence |
|---|---|
| Forged activation code | Ed25519 public key (PEM embedded in public-key.cts) |
| Code shared with another machine | payload.machine === computeMachineCode() (HKDF + APP_PEPPER) |
| Copying license blob to another machine | Storage is per-machine HKDF-keyed AES-GCM + HMAC |
| Clock rollback to dodge expiry | TimeGuard persists lastSeen + mtime anchor |
| Replacing license.dat with an older version | Three-mirror cross-check + HMAC-sealed state file |
| Downgrading expires | Replay protection — same lic id rejects shorter expires |
Renderer bypass of canEdit | Mutators consult editable-guard.assertEditable() (reads zustand directly); patching React state alone has no effect |
Side effects
- Writes under
app.getPath('userData'):license.dat,.lic-state.json,.lic-shadow.dat,.lic-clock.dat,.lic-machine.dat. - Registers four
ipcMain.handlecallbacks. - Starts a 60 s
setInterval. - Pushes broadcasts to every
BrowserWindow.
Test coverage
No repo-level unit tests (would live in electron/license/__tests__/). The CLI for issuing activation codes lives in tools/license-gen/ and uses the same Ed25519 private key (kept out of git) for local testing.
Consumers
electron/main.cts—whenReadyconstructs + starts;before-quitcallsstopelectron/preload.cts— must keepLICENSE_IPCconstants in sync
Source map
| Lines | Content |
|---|---|
| 11–17 | imports |
| 20–22 | TRIAL_DAYS / TRIAL_MS / STATUS_BROADCAST_CHANNEL |
| 24–29 | LICENSE_IPC |
| 31–58 | constructor |
| 60–74 | start() |
| 76–82 | stop() |
| 84–86 | getState() |
| 88–90 | getMachineCode() |
| 94–137 | activate(code) |
| 139–144 | deactivate() |
| 146–154 | refresh() |
| 156–292 | computeState() |
| 294–300 | broadcast() |
| 302–312 | failedActivation |
| 315–322 | summariseLicense |
See also
crypto— Ed25519 / AES-GCM / HMAC / HKDFstorage— three-mirror storagemachine-id— fingerprint generatortime-guard— clock tampering detectorpreload— IPC bridgelicenseStore— renderer mirrortools/license-gen/— private-key + activation-code CLI