210 lines
5.9 KiB
TypeScript
210 lines
5.9 KiB
TypeScript
import { hashFingerprint } from './crypto';
|
|
import type { HardwareFingerprint, LicenseType } from './types';
|
|
|
|
const FREE_REACTIVATION_WINDOW_DAYS = 30;
|
|
const FREE_REACTIVATION_WINDOW_MS = FREE_REACTIVATION_WINDOW_DAYS * 24 * 60 * 60 * 1000;
|
|
const FREE_FINGERPRINT_REUSE_THRESHOLD = 85;
|
|
const PAID_MULTI_DEVICE_ALERT_THRESHOLD = 2;
|
|
const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/i;
|
|
|
|
type ProjectDb = Awaited<ReturnType<typeof import('./db').getProjectDb>>;
|
|
|
|
export function calculateFingerprintMatchScore(
|
|
stored: HardwareFingerprint,
|
|
current: HardwareFingerprint
|
|
): number {
|
|
let score = 0;
|
|
let total = 0;
|
|
|
|
if (stored.machineId && stored.machineId === current.machineId) score += 40;
|
|
total += 40;
|
|
|
|
if (stored.hostname && stored.hostname === current.hostname) score += 20;
|
|
total += 20;
|
|
|
|
if (stored.macAddress && stored.macAddress === current.macAddress) score += 30;
|
|
total += 30;
|
|
|
|
if (stored.instanceId && stored.instanceId === current.instanceId) score += 10;
|
|
total += 10;
|
|
|
|
return (score / total) * 100;
|
|
}
|
|
|
|
function toHardwareFingerprint(value: unknown): HardwareFingerprint | null {
|
|
if (!value || typeof value !== 'object') return null;
|
|
|
|
const fingerprint = value as Partial<HardwareFingerprint>;
|
|
if (
|
|
typeof fingerprint.machineId !== 'string' ||
|
|
typeof fingerprint.hostname !== 'string' ||
|
|
typeof fingerprint.macAddress !== 'string' ||
|
|
typeof fingerprint.instanceId !== 'string'
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
machineId: fingerprint.machineId,
|
|
hostname: fingerprint.hostname,
|
|
macAddress: fingerprint.macAddress,
|
|
cpuId: typeof fingerprint.cpuId === 'string' ? fingerprint.cpuId : undefined,
|
|
instanceId: fingerprint.instanceId,
|
|
};
|
|
}
|
|
|
|
function isStoredFingerprintHash(value: string): boolean {
|
|
return SHA256_HEX_PATTERN.test(value);
|
|
}
|
|
|
|
export async function createPendingAlertOnce(
|
|
prisma: ProjectDb,
|
|
licenseId: string,
|
|
alertType: string,
|
|
details: Record<string, unknown>
|
|
): Promise<void> {
|
|
const existingAlert = await prisma.alert.findFirst({
|
|
where: {
|
|
licenseId,
|
|
alertType,
|
|
status: 'pending',
|
|
},
|
|
});
|
|
|
|
if (existingAlert) {
|
|
return;
|
|
}
|
|
|
|
await prisma.alert.create({
|
|
data: {
|
|
licenseId,
|
|
alertType,
|
|
details: {
|
|
...details,
|
|
detectedAt: new Date().toISOString(),
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function findFreeFingerprintReuse(
|
|
prisma: ProjectDb,
|
|
fingerprint: HardwareFingerprint
|
|
): Promise<{
|
|
reused: boolean;
|
|
existingLicenseKey?: string;
|
|
existingInstanceId?: string;
|
|
matchScore?: number;
|
|
}> {
|
|
const since = new Date(Date.now() - FREE_REACTIVATION_WINDOW_MS);
|
|
const recentFreeActivations = await prisma.activation.findMany({
|
|
where: {
|
|
isActive: true,
|
|
activatedAt: { gte: since },
|
|
license: {
|
|
licenseType: 'FREE',
|
|
status: 'active',
|
|
},
|
|
},
|
|
select: {
|
|
instanceId: true,
|
|
fingerprint: true,
|
|
license: {
|
|
select: {
|
|
licenseKey: true,
|
|
},
|
|
},
|
|
},
|
|
take: 200,
|
|
orderBy: { activatedAt: 'desc' },
|
|
});
|
|
|
|
for (const activation of recentFreeActivations) {
|
|
if (activation.instanceId === fingerprint.instanceId) {
|
|
continue;
|
|
}
|
|
|
|
const storedFingerprint = toHardwareFingerprint(activation.fingerprint);
|
|
if (!storedFingerprint) {
|
|
continue;
|
|
}
|
|
|
|
const matchScore = calculateFingerprintMatchScore(storedFingerprint, fingerprint);
|
|
const sameMachine = storedFingerprint.machineId === fingerprint.machineId;
|
|
const sameMacAddress =
|
|
storedFingerprint.macAddress !== 'unknown' &&
|
|
fingerprint.macAddress !== 'unknown' &&
|
|
storedFingerprint.macAddress === fingerprint.macAddress;
|
|
|
|
if (sameMachine || sameMacAddress || matchScore >= FREE_FINGERPRINT_REUSE_THRESHOLD) {
|
|
return {
|
|
reused: true,
|
|
existingLicenseKey: activation.license.licenseKey,
|
|
existingInstanceId: activation.instanceId,
|
|
matchScore,
|
|
};
|
|
}
|
|
}
|
|
|
|
return { reused: false };
|
|
}
|
|
|
|
export async function enforceDeviceAbusePolicy(
|
|
prisma: ProjectDb,
|
|
params: {
|
|
licenseId: string;
|
|
licenseType: LicenseType;
|
|
instanceId: string;
|
|
fingerprint: HardwareFingerprint;
|
|
}
|
|
): Promise<{ allowed: true } | { allowed: false; status: number; error: string }> {
|
|
const fingerprintHash = hashFingerprint(params.fingerprint);
|
|
|
|
const recentHeartbeats = await prisma.heartbeatLog.findMany({
|
|
where: {
|
|
licenseId: params.licenseId,
|
|
createdAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) },
|
|
},
|
|
select: {
|
|
instanceId: true,
|
|
fingerprintHash: true,
|
|
},
|
|
distinct: ['fingerprintHash'],
|
|
});
|
|
|
|
const normalizedHeartbeats = recentHeartbeats.filter((item) =>
|
|
isStoredFingerprintHash(item.fingerprintHash)
|
|
);
|
|
|
|
// 兼容旧版本免费注册首条心跳曾写入 machineId 的数据,避免升级后误判多设备。
|
|
const distinctFingerprints = new Set(normalizedHeartbeats.map((item) => item.fingerprintHash));
|
|
distinctFingerprints.add(fingerprintHash);
|
|
|
|
const distinctInstances = new Set(normalizedHeartbeats.map((item) => item.instanceId));
|
|
distinctInstances.add(params.instanceId);
|
|
|
|
if (params.licenseType === 'FREE' && (distinctFingerprints.size > 1 || distinctInstances.size > 1)) {
|
|
await createPendingAlertOnce(prisma, params.licenseId, 'free_multi_device_blocked', {
|
|
instanceIds: Array.from(distinctInstances),
|
|
fingerprintCount: distinctFingerprints.size,
|
|
fingerprintHash,
|
|
});
|
|
|
|
return {
|
|
allowed: false,
|
|
status: 403,
|
|
error: '免费版许可证检测到多设备复用,请重新激活或升级付费版',
|
|
};
|
|
}
|
|
|
|
if (params.licenseType !== 'FREE' && distinctFingerprints.size > PAID_MULTI_DEVICE_ALERT_THRESHOLD) {
|
|
await createPendingAlertOnce(prisma, params.licenseId, 'paid_multi_device_suspected', {
|
|
instanceIds: Array.from(distinctInstances),
|
|
fingerprintCount: distinctFingerprints.size,
|
|
fingerprintHash,
|
|
});
|
|
}
|
|
|
|
return { allowed: true };
|
|
}
|