From 596927c1c63c41b98c5759b924297b3be9715cc7 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Tue, 2 Jun 2026 17:35:29 +0800 Subject: [PATCH] =?UTF-8?q?refactor(app):=20=E5=B0=86=E8=AE=B8=E5=8F=AF?= =?UTF-8?q?=E8=AF=81=E6=96=87=E4=BB=B6=E5=8A=9F=E8=83=BD=E6=9B=BF=E6=8D=A2?= =?UTF-8?q?=E4=B8=BA=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 替换 LicenseFileCrypto 为 ConfigFileCrypto 加密类 - 将 /license-file 相关接口重命名为 /config-file - 修改数据库实体中的 licenseFileContent 字段为 configFileContent - 更新前端 API 调用从 downloadLicenseFile 改为 downloadConfigFile - 将安全中心的 License 文件解析功能改为 Config 文件解析 - 更新文件扩展名从 .xuqmlicense 改为 .xuqmconfig - 修改后端服务方法 ensureLicenseFile 为 ensureConfigFile - 调整加密解密逻辑以支持新的配置文件格式 --- .../common/security/ConfigFileCrypto.java | 119 ++++++++++++++++++ .../xuqm/tenant/controller/AppController.java | 30 ++--- .../com/xuqm/tenant/entity/AppEntity.java | 6 +- .../com/xuqm/tenant/service/AppService.java | 20 +-- 4 files changed, 147 insertions(+), 28 deletions(-) create mode 100644 common/src/main/java/com/xuqm/common/security/ConfigFileCrypto.java diff --git a/common/src/main/java/com/xuqm/common/security/ConfigFileCrypto.java b/common/src/main/java/com/xuqm/common/security/ConfigFileCrypto.java new file mode 100644 index 0000000..3b52c93 --- /dev/null +++ b/common/src/main/java/com/xuqm/common/security/ConfigFileCrypto.java @@ -0,0 +1,119 @@ +package com.xuqm.common.security; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import javax.crypto.Cipher; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; + +/** + * Encrypts/decrypts the SDK init config file (format: XUQM-CONFIG-V1.<salt>.<iv>.<ciphertext>). + * Algorithm: AES-256-GCM with PBKDF2-HMAC-SHA256 key derivation (120,000 iterations). + * This is separate from LicenseFileCrypto (device activation). + */ +public final class ConfigFileCrypto { + + private static final String MAGIC = "XUQM-CONFIG-V1"; + private static final String PASSPHRASE = "xuqm-config-file-v1.2026.internal"; + private static final int SALT_BYTES = 16; + private static final int IV_BYTES = 12; + private static final int KEY_BITS = 256; + private static final int ITERATIONS = 120_000; + private static final int GCM_TAG_BITS = 128; + private static final SecureRandom RANDOM = new SecureRandom(); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private ConfigFileCrypto() { + } + + public static String encrypt(String plainText) { + try { + byte[] salt = randomBytes(SALT_BYTES); + byte[] iv = randomBytes(IV_BYTES); + SecretKeySpec key = deriveKey(salt); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv)); + byte[] cipherText = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); + return String.join(".", + MAGIC, + Base64.getUrlEncoder().withoutPadding().encodeToString(salt), + Base64.getUrlEncoder().withoutPadding().encodeToString(iv), + Base64.getUrlEncoder().withoutPadding().encodeToString(cipherText)); + } catch (Exception e) { + throw new IllegalStateException("Failed to encrypt config file", e); + } + } + + public static ConfigPayload decrypt(String token) { + try { + String[] parts = token.split("\\."); + if (parts.length != 4 || !MAGIC.equals(parts[0])) { + throw new IllegalArgumentException("Invalid config file format"); + } + byte[] salt = Base64.getUrlDecoder().decode(parts[1]); + byte[] iv = Base64.getUrlDecoder().decode(parts[2]); + byte[] cipherText = Base64.getUrlDecoder().decode(parts[3]); + SecretKeySpec key = deriveKey(salt); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv)); + String plainText = new String(cipher.doFinal(cipherText), StandardCharsets.UTF_8); + JsonNode node = MAPPER.readTree(plainText); + return new ConfigPayload( + text(node, "appKey"), + text(node, "appName"), + text(node, "companyName"), + text(node, "packageName"), + text(node, "iosBundleId"), + text(node, "harmonyBundleName"), + text(node, "baseUrl"), + text(node, "serverUrl")); + } catch (IllegalArgumentException e) { + throw e; + } catch (Exception e) { + throw new IllegalArgumentException("Failed to decrypt config file: " + e.getMessage(), e); + } + } + + private static SecretKeySpec deriveKey(byte[] salt) throws Exception { + PBEKeySpec spec = new PBEKeySpec(PASSPHRASE.toCharArray(), salt, ITERATIONS, KEY_BITS); + byte[] encoded = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded(); + return new SecretKeySpec(encoded, "AES"); + } + + private static byte[] randomBytes(int size) { + byte[] bytes = new byte[size]; + RANDOM.nextBytes(bytes); + return bytes; + } + + private static String text(JsonNode node, String field) { + JsonNode v = node.path(field); + return v.isMissingNode() || v.isNull() ? null : v.asText(null); + } + + public record ConfigPayload( + String appKey, + String appName, + String companyName, + String packageName, + String iosBundleId, + String harmonyBundleName, + String baseUrl, + String serverUrl) { + + public boolean matchesPackageName(String candidate) { + if (candidate == null || candidate.isBlank()) return false; + boolean anyConfigured = hasText(packageName) || hasText(iosBundleId) || hasText(harmonyBundleName); + if (!anyConfigured) return true; + return candidate.equals(packageName) || candidate.equals(iosBundleId) || candidate.equals(harmonyBundleName); + } + + private static boolean hasText(String s) { return s != null && !s.isBlank(); } + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/AppController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/AppController.java index 4278387..1b6acf8 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/controller/AppController.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/AppController.java @@ -2,7 +2,7 @@ package com.xuqm.tenant.controller; import com.xuqm.common.model.ApiResponse; import com.xuqm.common.exception.BusinessException; -import com.xuqm.common.security.LicenseFileCrypto; +import com.xuqm.common.security.ConfigFileCrypto; import com.xuqm.tenant.dto.CreateAppRequest; import com.xuqm.tenant.entity.AppEntity; import com.xuqm.tenant.entity.FeatureServiceEntity; @@ -170,12 +170,12 @@ public class AppController { return ResponseEntity.ok(ApiResponse.success(result)); } - @GetMapping("/{appKey}/license-file") - public ResponseEntity downloadLicenseFile(@PathVariable String appKey, - @AuthenticationPrincipal String tenantId) { + @GetMapping("/{appKey}/config-file") + public ResponseEntity downloadConfigFile(@PathVariable String appKey, + @AuthenticationPrincipal String tenantId) { AppEntity app = appService.getByAppKey(appKey, tenantId); - String encrypted = appService.ensureLicenseFile(appKey, tenantId); - String filename = sanitizeFileName(app.getName()) + ".xuqmlicense"; + String encrypted = appService.ensureConfigFile(appKey, tenantId); + String filename = sanitizeFileName(app.getName()) + ".xuqmconfig"; return ResponseEntity.ok() .contentType(MediaType.APPLICATION_OCTET_STREAM) .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(filename, java.nio.charset.StandardCharsets.UTF_8).build().toString()) @@ -183,20 +183,20 @@ public class AppController { } /** - * Parse an uploaded license file and return its decrypted contents. - * Used by the security center to verify license file information. + * Parse an uploaded config file and return its decrypted contents. + * Used by the security center to verify config file information. */ - @PostMapping("/license/parse") - public ResponseEntity>> parseLicenseFile( + @PostMapping("/config/parse") + public ResponseEntity>> parseConfigFile( @RequestBody Map body, @AuthenticationPrincipal String tenantId) { String content = body.get("content"); if (content == null || content.isBlank()) { - throw new BusinessException("License file content is required"); + throw new BusinessException("Config file content is required"); } try { - LicenseFileCrypto.LicensePayload payload = LicenseFileCrypto.decrypt(content.trim()); - // Verify the license file belongs to the current tenant + ConfigFileCrypto.ConfigPayload payload = ConfigFileCrypto.decrypt(content.trim()); + // Verify the config file belongs to the current tenant try { appService.getByAppKey(payload.appKey(), tenantId); } catch (BusinessException e) { @@ -217,9 +217,9 @@ public class AppController { } catch (BusinessException e) { throw e; } catch (IllegalArgumentException e) { - throw new BusinessException("Invalid license file: " + e.getMessage()); + throw new BusinessException("Invalid config file: " + e.getMessage()); } catch (Exception e) { - throw new BusinessException("Failed to parse license file: " + e.getMessage()); + throw new BusinessException("Failed to parse config file: " + e.getMessage()); } } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/entity/AppEntity.java b/tenant-service/src/main/java/com/xuqm/tenant/entity/AppEntity.java index 91627d2..c96660b 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/entity/AppEntity.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/entity/AppEntity.java @@ -44,7 +44,7 @@ public class AppEntity { private LocalDateTime createdAt; @Column(name = "license_file_content", length = 4096) - private String licenseFileContent; + private String configFileContent; @Column(name = "is_default", columnDefinition = "BIT(1) DEFAULT 0") private boolean isDefault; @@ -91,6 +91,6 @@ public class AppEntity { public boolean isDeletable() { return deletable; } public void setDeletable(boolean deletable) { this.deletable = deletable; } - public String getLicenseFileContent() { return licenseFileContent; } - public void setLicenseFileContent(String licenseFileContent) { this.licenseFileContent = licenseFileContent; } + public String getConfigFileContent() { return configFileContent; } + public void setConfigFileContent(String configFileContent) { this.configFileContent = configFileContent; } } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/AppService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/AppService.java index 6d7ac3a..4e33183 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/AppService.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/AppService.java @@ -1,7 +1,7 @@ package com.xuqm.tenant.service; import com.xuqm.common.exception.BusinessException; -import com.xuqm.common.security.LicenseFileCrypto; +import com.xuqm.common.security.ConfigFileCrypto; import com.xuqm.tenant.dto.CreateAppRequest; import com.xuqm.tenant.entity.AppEntity; import com.xuqm.tenant.entity.FeatureServiceEntity; @@ -79,7 +79,7 @@ public class AppService { app.setAppKey(generateAppKey()); app.setAppSecret(generateSecret()); app.setCreatedAt(LocalDateTime.now()); - app.setLicenseFileContent(generateLicenseFileContent(app)); + app.setConfigFileContent(generateConfigFileContent(app)); AppEntity saved = appRepository.save(app); autoEnableFileService(saved.getAppKey()); operationLogService.record(tenantId, "APP", "APP", saved.getAppKey(), "CREATE_APP", @@ -89,12 +89,12 @@ public class AppService { } @Transactional - public String ensureLicenseFile(String appKey, String tenantId) { + public String ensureConfigFile(String appKey, String tenantId) { AppEntity app = getByAppKey(appKey, tenantId); - String content = app.getLicenseFileContent(); + String content = app.getConfigFileContent(); if (content == null || content.isBlank()) { - content = generateLicenseFileContent(app); - app.setLicenseFileContent(content); + content = generateConfigFileContent(app); + app.setConfigFileContent(content); appRepository.save(app); } return content; @@ -112,7 +112,7 @@ public class AppService { app.setName(req.name()); app.setDescription(req.description()); app.setIconUrl(req.iconUrl()); - app.setLicenseFileContent(generateLicenseFileContent(app)); + app.setConfigFileContent(generateConfigFileContent(app)); AppEntity saved = appRepository.save(app); Map after = new LinkedHashMap<>(); after.put("name", saved.getName()); @@ -167,7 +167,7 @@ public class AppService { return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); } - private String generateLicenseFileContent(AppEntity app) { + private String generateConfigFileContent(AppEntity app) { Map payload = new LinkedHashMap<>(); payload.put("appKey", app.getAppKey()); payload.put("appName", app.getName()); @@ -184,9 +184,9 @@ public class AppService { } payload.put("issuedAt", java.time.Instant.now().toString()); try { - return LicenseFileCrypto.encrypt(MAPPER.valueToTree(payload).toString()); + return ConfigFileCrypto.encrypt(MAPPER.valueToTree(payload).toString()); } catch (Exception e) { - throw new IllegalStateException("Failed to generate license file", e); + throw new IllegalStateException("Failed to generate config file", e); } }