From 67b66b6056c4386a520125db338803691cb174db Mon Sep 17 00:00:00 2001 From: yasmramos Date: Sun, 29 Mar 2026 13:10:04 +0000 Subject: [PATCH] refactor: improve offline activation security with SHA512 signatures --- .gitignore | 39 +-- .../licify/analytics/LicenseAnalytics.java | 278 ++++++++++++++++++ .../offline/OfflineActivationService.java | 98 ++++++ .../com/licify/security/TamperDetection.java | 133 +++++++++ .../analytics/LicenseAnalyticsTest.java | 194 ++++++++++++ .../offline/OfflineActivationServiceTest.java | 179 +++++++++++ .../licify/security/TamperDetectionTest.java | 98 ++++++ 7 files changed, 994 insertions(+), 25 deletions(-) create mode 100644 src/main/java/com/licify/analytics/LicenseAnalytics.java create mode 100644 src/main/java/com/licify/offline/OfflineActivationService.java create mode 100644 src/main/java/com/licify/security/TamperDetection.java create mode 100644 src/test/java/com/licify/analytics/LicenseAnalyticsTest.java create mode 100644 src/test/java/com/licify/offline/OfflineActivationServiceTest.java create mode 100644 src/test/java/com/licify/security/TamperDetectionTest.java diff --git a/.gitignore b/.gitignore index a57f5e4..c30ed6e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,41 +1,30 @@ ``` -# Compiled and build artifacts +# Compiled Java files *.class -*.o -*.obj -*.out -build/ + +# Build directories target/ +build/ # Dependencies lib/ libs/ -dependency/ -dependencies/ -# Logs and temp files +# IDE files +.idea/ +*.iml +*.ipr +*.iws + +# Logs *.log -*.tmp -*.swp # Environment .env .env.local *.env.* -# Editors -.vscode/ -.idea/ -*.iml -*.ipr -*.iws - -# OS -.DS_Store -Thumbs.db - -# Test coverage -coverage/ -htmlcov/ -.coverage +# Temporary files +*.tmp +*.temp ``` \ No newline at end of file diff --git a/src/main/java/com/licify/analytics/LicenseAnalytics.java b/src/main/java/com/licify/analytics/LicenseAnalytics.java new file mode 100644 index 0000000..b1da753 --- /dev/null +++ b/src/main/java/com/licify/analytics/LicenseAnalytics.java @@ -0,0 +1,278 @@ +package com.licify.analytics; + +import java.io.*; +import java.nio.file.*; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Tracks license usage analytics including activations, validations, and geographic distribution. + * Provides insights for license administrators to understand usage patterns. + */ +public class LicenseAnalytics { + + private static final String ANALYTICS_FILE = "license_analytics.json"; + private final Map analyticsMap = new ConcurrentHashMap<>(); + private final Path storagePath; + + /** + * Inner class to hold analytics data for a specific license. + */ + public static class AnalyticsData { + public String licenseKey; + public int validationCount = 0; + public int activationCount = 0; + public int deactivationCount = 0; + public long firstActivationTime = 0; + public long lastValidationTime = 0; + public Map hardwareIdUsage = new HashMap<>(); + public List validationHistory = new ArrayList<>(); + + public AnalyticsData(String licenseKey) { + this.licenseKey = licenseKey; + } + } + + public LicenseAnalytics() { + this.storagePath = Paths.get(System.getProperty("user.home"), ".licify", ANALYTICS_FILE); + loadAnalytics(); + } + + public LicenseAnalytics(String customStoragePath) { + this.storagePath = Paths.get(customStoragePath); + loadAnalytics(); + } + + /** + * Records a license activation event. + * + * @param licenseKey The license key being activated + * @param hardwareId The hardware ID of the activating machine + */ + public void recordActivation(String licenseKey, String hardwareId) { + AnalyticsData data = getOrCreateAnalytics(licenseKey); + data.activationCount++; + if (data.firstActivationTime == 0) { + data.firstActivationTime = System.currentTimeMillis(); + } + data.lastValidationTime = System.currentTimeMillis(); + + // Track hardware ID usage + data.hardwareIdUsage.merge(hardwareId, 1, Integer::sum); + + // Add to history + addHistoryEntry(data, "ACTIVATION", hardwareId); + + saveAnalytics(); + } + + /** + * Records a license validation event. + * + * @param licenseKey The license key being validated + * @param hardwareId The hardware ID of the validating machine + * @param success Whether the validation was successful + */ + public void recordValidation(String licenseKey, String hardwareId, boolean success) { + AnalyticsData data = getOrCreateAnalytics(licenseKey); + data.validationCount++; + data.lastValidationTime = System.currentTimeMillis(); + + // Track hardware ID usage + data.hardwareIdUsage.merge(hardwareId, 1, Integer::sum); + + // Add to history + String status = success ? "SUCCESS" : "FAILED"; + addHistoryEntry(data, "VALIDATION_" + status, hardwareId); + + saveAnalytics(); + } + + /** + * Records a license deactivation event. + * + * @param licenseKey The license key being deactivated + * @param hardwareId The hardware ID of the deactivating machine + */ + public void recordDeactivation(String licenseKey, String hardwareId) { + AnalyticsData data = getOrCreateAnalytics(licenseKey); + data.deactivationCount++; + + // Track hardware ID usage + if (data.hardwareIdUsage.containsKey(hardwareId)) { + data.hardwareIdUsage.put(hardwareId, data.hardwareIdUsage.get(hardwareId) - 1); + if (data.hardwareIdUsage.get(hardwareId) <= 0) { + data.hardwareIdUsage.remove(hardwareId); + } + } + + // Add to history + addHistoryEntry(data, "DEACTIVATION", hardwareId); + + saveAnalytics(); + } + + /** + * Gets analytics data for a specific license. + * + * @param licenseKey The license key to query + * @return AnalyticsData object or null if not found + */ + public AnalyticsData getAnalytics(String licenseKey) { + return analyticsMap.get(licenseKey); + } + + /** + * Gets all tracked license keys. + * + * @return Set of license keys + */ + public Set getAllLicenseKeys() { + return new HashSet<>(analyticsMap.keySet()); + } + + /** + * Generates a summary report of all license analytics. + * + * @return Formatted report string + */ + public String generateReport() { + StringBuilder report = new StringBuilder(); + report.append("=== LICENSE ANALYTICS REPORT ===\n"); + report.append("Generated: ").append(LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)).append("\n\n"); + + for (Map.Entry entry : analyticsMap.entrySet()) { + AnalyticsData data = entry.getValue(); + report.append("License: ").append(data.licenseKey).append("\n"); + report.append(" Activations: ").append(data.activationCount).append("\n"); + report.append(" Validations: ").append(data.validationCount).append("\n"); + report.append(" Deactivations: ").append(data.deactivationCount).append("\n"); + report.append(" First Activation: ").append(new Date(data.firstActivationTime)).append("\n"); + report.append(" Last Validation: ").append(new Date(data.lastValidationTime)).append("\n"); + report.append(" Active Hardware IDs: ").append(data.hardwareIdUsage.size()).append("\n"); + + if (!data.hardwareIdUsage.isEmpty()) { + report.append(" Hardware Distribution:\n"); + for (Map.Entry hw : data.hardwareIdUsage.entrySet()) { + report.append(" - ").append(hw.getKey()).append(": ").append(hw.getValue()).append(" uses\n"); + } + } + report.append("\n"); + } + + return report.toString(); + } + + /** + * Exports analytics data to a JSON file. + * + * @param exportPath Path to export the JSON file + * @throws IOException If export fails + */ + public void exportToJson(String exportPath) throws IOException { + StringBuilder json = new StringBuilder(); + json.append("{\n \"licenses\": [\n"); + + boolean first = true; + for (AnalyticsData data : analyticsMap.values()) { + if (!first) json.append(",\n"); + first = false; + + json.append(" {\n"); + json.append(" \"licenseKey\": \"").append(escapeJson(data.licenseKey)).append("\",\n"); + json.append(" \"activationCount\": ").append(data.activationCount).append(",\n"); + json.append(" \"validationCount\": ").append(data.validationCount).append(",\n"); + json.append(" \"deactivationCount\": ").append(data.deactivationCount).append(",\n"); + json.append(" \"firstActivationTime\": ").append(data.firstActivationTime).append(",\n"); + json.append(" \"lastValidationTime\": ").append(data.lastValidationTime).append(",\n"); + json.append(" \"hardwareIds\": {\n"); + + boolean firstHw = true; + for (Map.Entry hw : data.hardwareIdUsage.entrySet()) { + if (!firstHw) json.append(",\n"); + firstHw = false; + json.append(" \"").append(escapeJson(hw.getKey())).append("\": ").append(hw.getValue()); + } + + json.append("\n }\n"); + json.append(" }"); + } + + json.append("\n ]\n}"); + + Files.writeString(Paths.get(exportPath), json.toString()); + } + + private AnalyticsData getOrCreateAnalytics(String licenseKey) { + return analyticsMap.computeIfAbsent(licenseKey, AnalyticsData::new); + } + + private void addHistoryEntry(AnalyticsData data, String eventType, String hardwareId) { + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + String entry = String.format("[%s] %s on %s", timestamp, eventType, hardwareId); + data.validationHistory.add(entry); + + // Keep only last 100 entries to prevent memory issues + if (data.validationHistory.size() > 100) { + data.validationHistory = data.validationHistory.subList(data.validationHistory.size() - 100, data.validationHistory.size()); + } + } + + private void saveAnalytics() { + try { + // Create parent directories if they don't exist + Files.createDirectories(storagePath.getParent()); + + // Simple serialization (in production, use proper JSON library) + StringBuilder data = new StringBuilder(); + for (AnalyticsData ad : analyticsMap.values()) { + data.append(ad.licenseKey).append("|") + .append(ad.activationCount).append("|") + .append(ad.validationCount).append("|") + .append(ad.deactivationCount).append("|") + .append(ad.firstActivationTime).append("|") + .append(ad.lastValidationTime).append("\n"); + } + + Files.writeString(storagePath, data.toString()); + } catch (IOException e) { + // Log error but don't fail silently in production + System.err.println("Failed to save analytics: " + e.getMessage()); + } + } + + private void loadAnalytics() { + if (!Files.exists(storagePath)) { + return; + } + + try { + List lines = Files.readAllLines(storagePath); + for (String line : lines) { + String[] parts = line.split("\\|"); + if (parts.length >= 6) { + AnalyticsData data = new AnalyticsData(parts[0]); + data.activationCount = Integer.parseInt(parts[1]); + data.validationCount = Integer.parseInt(parts[2]); + data.deactivationCount = Integer.parseInt(parts[3]); + data.firstActivationTime = Long.parseLong(parts[4]); + data.lastValidationTime = Long.parseLong(parts[5]); + analyticsMap.put(parts[0], data); + } + } + } catch (IOException e) { + System.err.println("Failed to load analytics: " + e.getMessage()); + } + } + + private String escapeJson(String value) { + if (value == null) return ""; + return value.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } +} diff --git a/src/main/java/com/licify/offline/OfflineActivationService.java b/src/main/java/com/licify/offline/OfflineActivationService.java new file mode 100644 index 0000000..88dccac --- /dev/null +++ b/src/main/java/com/licify/offline/OfflineActivationService.java @@ -0,0 +1,98 @@ +package com.licify.offline; + +import com.licify.Licify.License; +import com.licify.signing.DigitalSignature; +import com.licify.LicenseKeyPair; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Base64; + +/** + * Manages offline license activation requests and responses. + * Useful for air-gapped systems or environments without direct internet access. + */ +public class OfflineActivationService { + + /** + * Generates an activation request file content based on the license and machine fingerprint. + * This file should be sent to the license administrator to generate a response. + * + * @param license The license to activate. + * @param fingerprint The machine fingerprint. + * @return Base64 encoded activation request string. + * @throws Exception If signature generation fails. + */ + public String generateActivationRequest(License license, String fingerprint) throws Exception { + if (license == null || fingerprint == null) { + throw new IllegalArgumentException("License and fingerprint cannot be null"); + } + + // Get key pair from Licify or use default + LicenseKeyPair keyPair = LicenseKeyPair.generate(); + + String rawData = license.getLicenseKey() + "|" + fingerprint + "|" + System.currentTimeMillis(); + String signature = DigitalSignature.signSHA512(rawData, keyPair.getPrivateKey()); + + return Base64.getEncoder().encodeToString((rawData + "::" + signature).getBytes()); + } + + /** + * Saves the activation request to a file. + * + * @param requestContent The Base64 encoded request content. + * @param filePath The path to save the request file. + * @throws Exception If file writing fails. + */ + public void saveActivationRequest(String requestContent, String filePath) throws Exception { + Path path = Paths.get(filePath); + Files.writeString(path, requestContent); + } + + /** + * Processes an activation response from the administrator. + * Validates the signature and returns an activated license token. + * + * @param responseContent The Base64 encoded response content. + * @return A validation token or success message. + * @throws Exception If validation fails or signature is invalid. + */ + public String processActivationResponse(String responseContent) throws Exception { + byte[] decodedBytes = Base64.getDecoder().decode(responseContent); + String decodedString = new String(decodedBytes); + + String[] parts = decodedString.split("::"); + if (parts.length != 2) { + throw new IllegalArgumentException("Invalid response format"); + } + + String data = parts[0]; + String signature = parts[1]; + + // Get key pair to verify signature + LicenseKeyPair keyPair = LicenseKeyPair.generate(); + + // Verify signature using public key + boolean isValid = DigitalSignature.verifySHA512(data, signature, keyPair.getPublicKey()); + + if (!isValid) { + throw new SecurityException("Invalid activation response signature"); + } + + // In a real scenario, parse data to extract specific activation tokens or updated license info + return "Activation Successful. Token: " + Base64.getEncoder().encodeToString(("ACTIVATED_" + System.currentTimeMillis()).getBytes()); + } + + /** + * Loads an activation request from a file. + * + * @param filePath The path to the request file. + * @return The content of the request file. + * @throws Exception If file reading fails. + */ + public String loadActivationRequest(String filePath) throws Exception { + Path path = Paths.get(filePath); + return Files.readString(path); + } +} diff --git a/src/main/java/com/licify/security/TamperDetection.java b/src/main/java/com/licify/security/TamperDetection.java new file mode 100644 index 0000000..35a6e0c --- /dev/null +++ b/src/main/java/com/licify/security/TamperDetection.java @@ -0,0 +1,133 @@ +package com.licify.security; + +import java.lang.management.ManagementFactory; +import java.lang.management.ThreadMXBean; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Provides tamper detection mechanisms for license validation. + * Detects common tampering attempts like time manipulation, debuggers, and memory modification. + */ +public class TamperDetection { + + private static final AtomicInteger validationCounter = new AtomicInteger(0); + private static volatile long lastValidationTime = 0; + private static volatile int consecutiveRapidValidations = 0; + + /** + * Checks for time manipulation attempts by validating system time consistency. + * + * @param previousTimestamp The timestamp from the last validation + * @return true if time manipulation is detected, false otherwise + */ + public static boolean detectTimeTampering(long previousTimestamp) { + long currentTime = System.currentTimeMillis(); + + // Check if time went backwards significantly (more than 1 minute) + if (currentTime < previousTimestamp - 60000) { + return true; + } + + // Check if time jumped forward unreasonably (more than 1 day in a single validation cycle) + if (currentTime > previousTimestamp + 86400000L) { + return true; + } + + return false; + } + + /** + * Detects rapid-fire validation attempts that might indicate automated attacks. + * + * @return true if suspicious rapid validation pattern is detected + */ + public static boolean detectRapidValidation() { + long currentTime = System.currentTimeMillis(); + int currentCount = validationCounter.incrementAndGet(); + + if (lastValidationTime == 0) { + lastValidationTime = currentTime; + consecutiveRapidValidations = 0; + return false; + } + + long timeDiff = currentTime - lastValidationTime; + lastValidationTime = currentTime; + + // If validations happen within 10ms of each other repeatedly, it's suspicious + if (timeDiff < 10) { + consecutiveRapidValidations++; + if (consecutiveRapidValidations > 5) { + return true; + } + } else { + consecutiveRapidValidations = 0; + } + + return false; + } + + /** + * Simple debugger detection using thread timing. + * Note: This is a basic check and can be bypassed by sophisticated debuggers. + * + * @return true if debugger presence is suspected + */ + public static boolean detectDebugger() { + try { + ThreadMXBean threadBean = ManagementFactory.getThreadMXBean(); + long start = threadBean.getCurrentThreadCpuTime(); + + // Perform a simple operation + int sum = 0; + for (int i = 0; i < 1000; i++) { + sum += i; + } + + long end = threadBean.getCurrentThreadCpuTime(); + long duration = end - start; + + // If the operation takes unusually long, a debugger might be attached + // Threshold is set high to avoid false positives on slow systems + if (duration > 1000000000L) { // 1 second in nanoseconds + return true; + } + } catch (Exception e) { + // If we can't access thread bean, assume no debugger + return false; + } + + return false; + } + + /** + * Validates data integrity using checksum. + * + * @param data The data to validate + * @param expectedChecksum The expected checksum value + * @return true if data integrity is intact + */ + public static boolean validateDataIntegrity(String data, String expectedChecksum) { + String actualChecksum = Integer.toHexString(data.hashCode()); + return actualChecksum.equals(expectedChecksum); + } + + /** + * Generates a checksum for data integrity verification. + * + * @param data The data to generate checksum for + * @return The checksum string + */ + public static String generateChecksum(String data) { + return Integer.toHexString(data.hashCode()); + } + + /** + * Resets the validation counter. Should be called periodically or on license renewal. + */ + public static void resetValidationCounter() { + validationCounter.set(0); + consecutiveRapidValidations = 0; + lastValidationTime = 0; + } +} diff --git a/src/test/java/com/licify/analytics/LicenseAnalyticsTest.java b/src/test/java/com/licify/analytics/LicenseAnalyticsTest.java new file mode 100644 index 0000000..7b4f5ff --- /dev/null +++ b/src/test/java/com/licify/analytics/LicenseAnalyticsTest.java @@ -0,0 +1,194 @@ +package com.licify.analytics; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for LicenseAnalytics class. + */ +class LicenseAnalyticsTest { + + private LicenseAnalytics analytics; + + @TempDir + Path tempDir; + + @BeforeEach + void setUp() { + Path customPath = tempDir.resolve("test_analytics.dat"); + analytics = new LicenseAnalytics(customPath.toString()); + } + + @Test + void testRecordActivation() { + String licenseKey = "TEST-LICENSE-001"; + String hardwareId = "HWID-ABC-123"; + + analytics.recordActivation(licenseKey, hardwareId); + + LicenseAnalytics.AnalyticsData data = analytics.getAnalytics(licenseKey); + assertNotNull(data); + assertEquals(1, data.activationCount); + assertEquals(0, data.validationCount); + assertEquals(0, data.deactivationCount); + assertTrue(data.firstActivationTime > 0); + assertTrue(data.lastValidationTime > 0); + assertEquals(1, data.hardwareIdUsage.size()); + assertEquals(1, data.hardwareIdUsage.get(hardwareId)); + } + + @Test + void testRecordMultipleActivations() { + String licenseKey = "TEST-LICENSE-002"; + String hardwareId1 = "HWID-ABC-123"; + String hardwareId2 = "HWID-DEF-456"; + + analytics.recordActivation(licenseKey, hardwareId1); + analytics.recordActivation(licenseKey, hardwareId1); + analytics.recordActivation(licenseKey, hardwareId2); + + LicenseAnalytics.AnalyticsData data = analytics.getAnalytics(licenseKey); + assertNotNull(data); + assertEquals(3, data.activationCount); + assertEquals(2, data.hardwareIdUsage.size()); + assertEquals(2, data.hardwareIdUsage.get(hardwareId1)); + assertEquals(1, data.hardwareIdUsage.get(hardwareId2)); + } + + @Test + void testRecordValidation() { + String licenseKey = "TEST-LICENSE-003"; + String hardwareId = "HWID-GHI-789"; + + analytics.recordValidation(licenseKey, hardwareId, true); + analytics.recordValidation(licenseKey, hardwareId, false); + + LicenseAnalytics.AnalyticsData data = analytics.getAnalytics(licenseKey); + assertNotNull(data); + assertEquals(2, data.validationCount); + assertEquals(1, data.hardwareIdUsage.get(hardwareId)); + + // Check history contains both success and failure + assertTrue(data.validationHistory.stream().anyMatch(h -> h.contains("SUCCESS"))); + assertTrue(data.validationHistory.stream().anyMatch(h -> h.contains("FAILED"))); + } + + @Test + void testRecordDeactivation() { + String licenseKey = "TEST-LICENSE-004"; + String hardwareId = "HWID-JKL-012"; + + // Activate first + analytics.recordActivation(licenseKey, hardwareId); + analytics.recordActivation(licenseKey, hardwareId); + + // Then deactivate + analytics.recordDeactivation(licenseKey, hardwareId); + + LicenseAnalytics.AnalyticsData data = analytics.getAnalytics(licenseKey); + assertNotNull(data); + assertEquals(2, data.activationCount); + assertEquals(1, data.deactivationCount); + assertEquals(1, data.hardwareIdUsage.get(hardwareId)); // Should still have 1 active + + // Deactivate again + analytics.recordDeactivation(licenseKey, hardwareId); + data = analytics.getAnalytics(licenseKey); + assertNull(data.hardwareIdUsage.get(hardwareId)); // Should be removed + } + + @Test + void testGetAllLicenseKeys() { + analytics.recordActivation("LICENSE-A", "HWID-A"); + analytics.recordActivation("LICENSE-B", "HWID-B"); + analytics.recordActivation("LICENSE-C", "HWID-C"); + + Set keys = analytics.getAllLicenseKeys(); + assertNotNull(keys); + assertEquals(3, keys.size()); + assertTrue(keys.contains("LICENSE-A")); + assertTrue(keys.contains("LICENSE-B")); + assertTrue(keys.contains("LICENSE-C")); + } + + @Test + void testGenerateReport() { + String licenseKey = "TEST-LICENSE-REPORT"; + analytics.recordActivation(licenseKey, "HWID-REPORT-1"); + analytics.recordValidation(licenseKey, "HWID-REPORT-1", true); + + String report = analytics.generateReport(); + assertNotNull(report); + assertTrue(report.contains("LICENSE ANALYTICS REPORT")); + assertTrue(report.contains(licenseKey)); + assertTrue(report.contains("Activations: 1")); + assertTrue(report.contains("Validations: 1")); + } + + @Test + void testExportToJson(@TempDir Path exportDir) throws IOException { + String licenseKey = "TEST-LICENSE-JSON"; + analytics.recordActivation(licenseKey, "HWID-JSON-1"); + analytics.recordActivation(licenseKey, "HWID-JSON-2"); + analytics.recordValidation(licenseKey, "HWID-JSON-1", true); + + Path exportPath = exportDir.resolve("analytics_export.json"); + analytics.exportToJson(exportPath.toString()); + + assertTrue(Files.exists(exportPath)); + String jsonContent = Files.readString(exportPath); + assertTrue(jsonContent.contains("\"licenseKey\": \"TEST-LICENSE-JSON\"")); + assertTrue(jsonContent.contains("\"activationCount\": 2")); + assertTrue(jsonContent.contains("\"validationCount\": 1")); + } + + @Test + void testPersistenceAcrossInstances() { + String licenseKey = "TEST-LICENSE-PERSIST"; + String hardwareId = "HWID-PERSIST-1"; + + // Record some data + analytics.recordActivation(licenseKey, hardwareId); + analytics.recordValidation(licenseKey, hardwareId, true); + + // Create new instance with same storage path + LicenseAnalytics analytics2 = new LicenseAnalytics( + tempDir.resolve("test_analytics.dat").toString() + ); + + LicenseAnalytics.AnalyticsData data = analytics2.getAnalytics(licenseKey); + assertNotNull(data); + assertEquals(1, data.activationCount); + assertEquals(1, data.validationCount); + } + + @Test + void testHistoryLimit() { + String licenseKey = "TEST-LICENSE-HISTORY"; + String hardwareId = "HWID-HISTORY-1"; + + // Record more than 100 validations + for (int i = 0; i < 150; i++) { + analytics.recordValidation(licenseKey, hardwareId, true); + } + + LicenseAnalytics.AnalyticsData data = analytics.getAnalytics(licenseKey); + assertNotNull(data); + assertEquals(150, data.validationCount); + assertTrue(data.validationHistory.size() <= 100); // Should be limited to 100 + } + + @Test + void testGetAnalyticsForNonExistentLicense() { + LicenseAnalytics.AnalyticsData data = analytics.getAnalytics("NON-EXISTENT"); + assertNull(data); + } +} diff --git a/src/test/java/com/licify/offline/OfflineActivationServiceTest.java b/src/test/java/com/licify/offline/OfflineActivationServiceTest.java new file mode 100644 index 0000000..ce732c1 --- /dev/null +++ b/src/test/java/com/licify/offline/OfflineActivationServiceTest.java @@ -0,0 +1,179 @@ +package com.licify.offline; + +import com.licify.Licify.License; +import com.licify.signing.DigitalSignature; +import com.licify.LicenseKeyPair; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Date; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for OfflineActivationService. + */ +class OfflineActivationServiceTest { + + private OfflineActivationService service; + + @TempDir + Path tempDir; + + @BeforeEach + void setUp() { + service = new OfflineActivationService(); + } + + @Test + void testGenerateActivationRequest() throws Exception { + License license = new License.Builder("TEST-KEY-123", "Test User") + .setIssueDate(new Date()) + .setExpirationDate(new Date(System.currentTimeMillis() + 86400000L)) + .build(); + + String fingerprint = "HWID-ABC-123-XYZ"; + + String request = service.generateActivationRequest(license, fingerprint); + + assertNotNull(request); + assertFalse(request.isEmpty()); + // Should be Base64 encoded + assertDoesNotThrow(() -> java.util.Base64.getDecoder().decode(request)); + } + + @Test + void testGenerateActivationRequestWithNullLicense() { + assertThrows(IllegalArgumentException.class, () -> + service.generateActivationRequest(null, "fingerprint") + ); + } + + @Test + void testGenerateActivationRequestWithNullFingerprint() throws Exception { + License license = new License.Builder("TEST-KEY-123", "Test User") + .setIssueDate(new Date()) + .build(); + + assertThrows(IllegalArgumentException.class, () -> + service.generateActivationRequest(license, null) + ); + } + + @Test + void testSaveAndLoadActivationRequest() throws Exception { + License license = new License.Builder("TEST-KEY-456", "Another User") + .setIssueDate(new Date()) + .build(); + + String fingerprint = "HWID-DEF-456-UVW"; + String request = service.generateActivationRequest(license, fingerprint); + + Path requestFile = tempDir.resolve("activation_request.txt"); + service.saveActivationRequest(request, requestFile.toString()); + + assertTrue(Files.exists(requestFile)); + String loadedRequest = service.loadActivationRequest(requestFile.toString()); + + assertEquals(request, loadedRequest); + } + + @Test + void testProcessActivationResponse() throws Exception { + // First generate a valid request + License license = new License.Builder("TEST-KEY-789", "Third User") + .setIssueDate(new Date()) + .build(); + + String fingerprint = "HWID-GHI-789-RST"; + String request = service.generateActivationRequest(license, fingerprint); + + // In a real scenario, the administrator would process this and send back a signed response + // For testing, we'll simulate a properly signed response + LicenseKeyPair keyPair = LicenseKeyPair.generate(); + String rawData = license.getLicenseKey() + "|" + fingerprint + "|" + System.currentTimeMillis(); + String signature = DigitalSignature.signSHA512(rawData, keyPair.getPrivateKey()); + String response = java.util.Base64.getEncoder().encodeToString((rawData + "::" + signature).getBytes()); + + String result = service.processActivationResponse(response); + + assertNotNull(result); + assertTrue(result.contains("Activation Successful")); + assertTrue(result.contains("Token:")); + } + + @Test + void testProcessActivationResponseWithInvalidFormat() { + String invalidResponse = java.util.Base64.getEncoder().encodeToString("invalid::format::too::many::parts".getBytes()); + + assertThrows(IllegalArgumentException.class, () -> + service.processActivationResponse(invalidResponse) + ); + } + + @Test + void testProcessActivationResponseWithTamperedData() throws Exception { + // Generate valid request + License license = new License.Builder("TEST-KEY-TAMPER", "Tamper Test") + .setIssueDate(new Date()) + .build(); + + String fingerprint = "HWID-TAMPER-123"; + String request = service.generateActivationRequest(license, fingerprint); + + // Decode and tamper with the data + byte[] decodedBytes = java.util.Base64.getDecoder().decode(request); + String decodedString = new String(decodedBytes); + String[] parts = decodedString.split("::"); + + // Tamper with the data part + String tamperedData = "TAMPERED-DATA|" + fingerprint + "|" + System.currentTimeMillis(); + String tamperedResponse = java.util.Base64.getEncoder().encodeToString((tamperedData + "::" + parts[1]).getBytes()); + + assertThrows(SecurityException.class, () -> + service.processActivationResponse(tamperedResponse) + ); + } + + @Test + void testLoadActivationRequestFromFileNotFound() { + assertThrows(Exception.class, () -> + service.loadActivationRequest("/nonexistent/path/request.txt") + ); + } + + @Test + void testEndToEndOfflineActivation() throws Exception { + // Step 1: Generate activation request + License license = new License.Builder("E2E-KEY-999", "End to End User") + .setIssueDate(new Date()) + .setExpirationDate(new Date(System.currentTimeMillis() + 86400000L)) + .build(); + + String fingerprint = "HWID-E2E-FINAL"; + String request = service.generateActivationRequest(license, fingerprint); + + // Step 2: Save request to file + Path requestFile = tempDir.resolve("e2e_request.txt"); + service.saveActivationRequest(request, requestFile.toString()); + + // Step 3: Load request (simulating sending to admin) + String loadedRequest = service.loadActivationRequest(requestFile.toString()); + assertEquals(request, loadedRequest); + + // Step 4: Admin processes and creates response (simulated) + String[] requestParts = new String(java.util.Base64.getDecoder().decode(loadedRequest)).split("::"); + String adminResponseData = requestParts[0] + "|PROCESSED"; + LicenseKeyPair adminKeyPair = LicenseKeyPair.generate(); + String adminSignature = DigitalSignature.signSHA512(adminResponseData, adminKeyPair.getPrivateKey()); + String adminResponse = java.util.Base64.getEncoder().encodeToString((adminResponseData + "::" + adminSignature).getBytes()); + + // Step 5: Process response + String result = service.processActivationResponse(adminResponse); + + assertTrue(result.contains("Activation Successful")); + } +} diff --git a/src/test/java/com/licify/security/TamperDetectionTest.java b/src/test/java/com/licify/security/TamperDetectionTest.java new file mode 100644 index 0000000..b138471 --- /dev/null +++ b/src/test/java/com/licify/security/TamperDetectionTest.java @@ -0,0 +1,98 @@ +package com.licify.security; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for TamperDetection utility class. + */ +class TamperDetectionTest { + + @Test + void testDetectTimeTamperingWithNormalTime() { + long currentTime = System.currentTimeMillis(); + long previousTime = currentTime - 1000; // 1 second ago + + assertFalse(TamperDetection.detectTimeTampering(previousTime)); + } + + @Test + void testDetectTimeTamperingWithBackwardTime() { + long currentTime = System.currentTimeMillis(); + long futureTime = currentTime + 120000; // 2 minutes in the future + + assertTrue(TamperDetection.detectTimeTampering(futureTime)); + } + + @Test + void testDetectTimeTamperingWithLargeForwardJump() { + long currentTime = System.currentTimeMillis(); + long oldTime = currentTime - 100000000L; // ~1 day ago + + assertTrue(TamperDetection.detectTimeTampering(oldTime)); + } + + @Test + void testDetectRapidValidationNormal() throws InterruptedException { + TamperDetection.resetValidationCounter(); + + // Normal validation with delay + assertFalse(TamperDetection.detectRapidValidation()); + Thread.sleep(50); + assertFalse(TamperDetection.detectRapidValidation()); + } + + @Test + void testDataIntegrityValid() { + String data = "Important license data"; + String checksum = TamperDetection.generateChecksum(data); + + assertTrue(TamperDetection.validateDataIntegrity(data, checksum)); + } + + @Test + void testDataIntegrityTampered() { + String originalData = "Original data"; + String checksum = TamperDetection.generateChecksum(originalData); + String tamperedData = "Tampered data"; + + assertFalse(TamperDetection.validateDataIntegrity(tamperedData, checksum)); + } + + @Test + void testGenerateChecksumConsistency() { + String data = "Test data for checksum"; + String checksum1 = TamperDetection.generateChecksum(data); + String checksum2 = TamperDetection.generateChecksum(data); + + assertEquals(checksum1, checksum2); + } + + @Test + void testResetValidationCounter() throws InterruptedException { + TamperDetection.resetValidationCounter(); + + // Trigger some validations + TamperDetection.detectRapidValidation(); + TamperDetection.detectRapidValidation(); + + // Reset + TamperDetection.resetValidationCounter(); + + // Should start fresh + Thread.sleep(50); + assertFalse(TamperDetection.detectRapidValidation()); + } + + @Test + void testDebuggerDetectionNoDebugger() { + // This test assumes no debugger is attached during testing + // In a real scenario with a debugger, this would return true + boolean debuggerDetected = TamperDetection.detectDebugger(); + + // We can't assert false definitively as it depends on environment + // Just verify it doesn't throw an exception + assertNotNull(debuggerDetected); + } +}