License time-guard
Source:
electron/license/time-guard.cts
Overview
TimeGuard is the license layer's defense against system-clock manipulation. It records monotonic evidence — a strictly-increasing high-water-mark timestamp, plus a session counter and binary-mtime anchor — and flags the install as tampered if a future Date.now() ever falls behind that evidence.
The guard's persisted state itself is encrypted with a per-machine key and HMAC-sealed, so swapping in an old .lic-clock.dat from a backup is detectable.
Exports
export interface TimeGuardSnapshot {
now: number;
lastSeen: number;
firstSeen: number;
sessions: number;
tampered: boolean;
tamperedReason?: string;
/** True if the wallclock is behind persisted time evidence. */
suspiciousNow: boolean;
}
export class TimeGuard {
constructor(userDataDir: string, machineCode: string, anchorPaths: string[]);
start(): void;
stop(): void;
trustedNow(): number;
snapshot(): TimeGuardSnapshot;
markTampered(reason: string): void;
reset(anchorPaths: string[]): void;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Behavior
Defenses (in order of cost-to-bypass)
Monotonic high-water-mark. Every minute (and on start / exit),
lastSeen = max(lastSeen, Date.now())is persisted. A future call seeingDate.now() < lastSeen - GRACE_MSflags tampered.Anchor mtimes. The Electron binary,
package.json, andapp.getAppPath()all carry mtimes set at install time. Seeingnow < anchorMtimeMsis impossible without rolling the clock back behind the install date.Session counter. Independent of wallclock, persists across restarts. Provides a "minimum elapsed" bound that license expiry can layer with a soft cap (e.g. "expired-license soft-fail after N additional sessions even if the wallclock looks fresh").
Forward jumps are tolerated. A forward wallclock jump cannot extend a trial or license window, and OS sleep/background suspension can look identical because app timers pause. Rollback remains fail-closed through the high-water-mark check.
const GRACE_MS = 5 * 60 * 1000; // 5min for NTP/DST quirks
const TICK_INTERVAL_MS = 60 * 1000;2
Persisted state shape
interface TimeStateV1 {
v: 1;
lastSeen: number;
firstSeen: number;
sessions: number;
ticks: number;
anchorMtimeMs: number;
tampered: boolean;
tamperedReason?: string;
}2
3
4
5
6
7
8
9
10
Stored at <userData>/.lic-clock.dat, AES-256-GCM encrypted with getFileKey(machineCode, 'clock') and prefixed by a hex HMAC line.
Tick
private tick(): void {
const now = Date.now();
// (1) Rollback check
if (now + GRACE_MS < this.state.lastSeen) {
this.markTampered(`wallclock rollback: now=${now} < lastSeen=${this.state.lastSeen}`);
}
if (now > this.state.lastSeen) this.state.lastSeen = now;
this.state.ticks += 1;
this.persist();
}2
3
4
5
6
7
8
9
10
11
12
Sticky tampered
Once tampered === true and a tamperedReason is recorded, the flag never clears in this code path. Recovery requires a fresh install (delete userData) — see reset(anchorPaths), which is not exposed in production.
trustedNow
trustedNow(): number {
return Math.max(Date.now(), this.state.lastSeen);
}2
3
The license manager calls trustedNow() instead of Date.now() for all expiry decisions. Even if the user spins the wallclock backwards mid-session, lastSeen clamps the floor — they can't extend a trial by setting the clock to last week.
detectDrift
private detectDrift(): string | null {
if (now + GRACE_MS < this.state.lastSeen) return `now < lastSeen`;
if (this.state.anchorMtimeMs > 0 && now + GRACE_MS < this.state.anchorMtimeMs)
return `now < anchorMtime`;
return null;
}2
3
4
5
6
Surfaced via snapshot().suspiciousNow. Used by the UI to show a "clock looks suspicious" hint without going all the way to tamper mode.
Bootstrap
private bootstrap(anchorPaths: string[]): TimeStateV1 {
const now = Date.now();
let anchorMtimeMs = 0;
for (const p of anchorPaths) {
if (existsSync(p)) anchorMtimeMs = Math.max(anchorMtimeMs, statSync(p).mtimeMs);
}
return { v: 1, lastSeen: now, firstSeen: now, sessions: 0, ticks: 0, anchorMtimeMs, tampered: false };
}2
3
4
5
6
7
8
First run captures the latest anchor mtime — that's the install date. Subsequent runs reuse the persisted state.
Persistence flow
Load failure modes
// HMAC mismatch
return { ..., tampered: true, tamperedReason: 'time-state HMAC mismatch' };
// AEAD decrypt failed (wrong machine key, corrupted ciphertext)
return { ..., tampered: true, tamperedReason: 'time-state AEAD decrypt failed' };2
3
4
5
Both produce a tampered state immediately rather than rebuilding — the persisted file's existence is itself evidence the user has run before, and a corrupted file shouldn't reset the trial clock.
Examples
Boot and read trustedNow
const tg = new TimeGuard(userDataDir, machineCode, anchorPaths);
tg.start();
console.log('trusted now:', new Date(tg.trustedNow()).toISOString());2
3
Inspect tampering
const snap = tg.snapshot();
if (snap.tampered) {
console.warn('reason:', snap.tamperedReason);
}2
3
4
Force tamper for tests
tg.markTampered('test-only manual mark');Admin recovery (not exposed in production)
tg.reset(anchorPaths); // clears tampered flag, recomputes anchor mtimereset(...) is private to the API; nothing in main calls it. It exists for future support tooling.
Related
- License crypto —
aesEncrypt,getFileKey,getMacKey - License manager — calls
trustedNow()andsnapshot() - License storage — same encryption pattern
- Architecture: license system