fix(license): bind deviceId to physical machine, not OS user

Use OS machine identifiers (Windows MachineGuid, macOS IOPlatformUUID, Linux machine-id) hashed as deviceId so all accounts on one PC share one license slot. Keep legacy userData/device.id for migration and retire it on re-activation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-23 10:39:46 +08:00
parent c1c332364c
commit 32a5479086
11 changed files with 399 additions and 24 deletions
+32 -8
View File
@@ -10,7 +10,7 @@ import type { LicensePayloadV1 } from '../../shared/license/payloadV1';
import { isProductKey } from '../../shared/license/productKey';
import { normalizeLicenseTokenInput } from '../../shared/license/tokenFormat';
import { getOrCreateDeviceId } from './deviceId';
import { clearLegacyDeviceId, getOrCreateDeviceId, readLegacyDeviceId } from './deviceId';
import { licenseEncryptedPath, licenseFallbackSealedPath, preferencesPath } from './paths';
import { verifyLicenseToken } from './verifyLicenseToken';
@@ -66,12 +66,28 @@ function emitLicenseStatusChanged(): void {
export class LicenseService {
private readonly userData: string;
private readonly deviceId: string;
/** UUID из старого userData/device.id — только для совместимости до повторной активации. */
private legacyDeviceId: string | null;
private lastRemoteRevokeCheckMs = 0;
private lastRemoteRevoked = false;
constructor(userData: string) {
this.userData = userData;
this.deviceId = getOrCreateDeviceId(userData);
const legacy = readLegacyDeviceId(userData);
this.legacyDeviceId = legacy && legacy !== this.deviceId ? legacy : null;
}
private verifyOpts(nowSec: number): {
nowSec: number;
deviceId: string;
alsoAcceptDeviceIds?: readonly string[];
} {
return {
nowSec,
deviceId: this.deviceId,
...(this.legacyDeviceId ? { alsoAcceptDeviceIds: [this.legacyDeviceId] } : {}),
};
}
private isSkipLicense(): boolean {
@@ -190,10 +206,17 @@ export class LicenseService {
private async activateWithProductKey(productKey: string): Promise<string> {
const base = this.resolveLicenseActivateBaseUrl();
const url = new URL('v1/activate', base);
const body: { productKey: string; deviceId: string; retireDeviceId?: string } = {
productKey: productKey.trim(),
deviceId: this.deviceId,
};
if (this.legacyDeviceId) {
body.retireDeviceId = this.legacyDeviceId;
}
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ productKey: productKey.trim(), deviceId: this.deviceId }),
body: JSON.stringify(body),
signal: AbortSignal.timeout(20_000),
});
const text = await res.text();
@@ -211,6 +234,10 @@ export class LicenseService {
if (!token || typeof token !== 'string') {
throw new Error('LICENSE_ACTIVATE_FAILED:token_missing');
}
if (this.legacyDeviceId) {
clearLegacyDeviceId(this.userData);
this.legacyDeviceId = null;
}
return normalizeLicenseTokenInput(token);
}
@@ -278,7 +305,7 @@ export class LicenseService {
};
}
const v = verifyLicenseToken(token, { nowSec, deviceId: this.deviceId });
const v = verifyLicenseToken(token, this.verifyOpts(nowSec));
if (!v.ok) {
return {
active: false,
@@ -328,10 +355,7 @@ export class LicenseService {
if (!base.active || !base.summary) return base;
const token = this.readSealedToken();
if (!token?.trim()) return base;
const v = verifyLicenseToken(token, {
nowSec: Math.floor(Date.now() / 1000),
deviceId: this.deviceId,
});
const v = verifyLicenseToken(token, this.verifyOpts(Math.floor(Date.now() / 1000)));
if (!v.ok) return this.getStatusSync();
void this.maybeRefreshRemoteRevocation(v.payload);
return this.getStatusSync();
@@ -346,7 +370,7 @@ export class LicenseService {
trimmed = await this.activateWithProductKey(trimmed);
}
const nowSec = Math.floor(Date.now() / 1000);
const v = verifyLicenseToken(trimmed, { nowSec, deviceId: this.deviceId });
const v = verifyLicenseToken(trimmed, this.verifyOpts(nowSec));
if (!v.ok) {
throw new Error(`LICENSE_INVALID:${v.reason}`);
}