refactor(app): 将许可证文件功能替换为配置文件功能

- 替换 LicenseFileCrypto 为 ConfigFileCrypto 加密类
- 将 /license-file 相关接口重命名为 /config-file
- 修改数据库实体中的 licenseFileContent 字段为 configFileContent
- 更新前端 API 调用从 downloadLicenseFile 改为 downloadConfigFile
- 将安全中心的 License 文件解析功能改为 Config 文件解析
- 更新文件扩展名从 .xuqmlicense 改为 .xuqmconfig
- 修改后端服务方法 ensureLicenseFile 为 ensureConfigFile
- 调整加密解密逻辑以支持新的配置文件格式
这个提交包含在:
XuqmGroup 2026-06-02 17:35:29 +08:00
父节点 21fa87b3ac
当前提交 596927c1c6
共有 4 个文件被更改,包括 147 次插入28 次删除

查看文件

@ -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(); }
}
}

查看文件

@ -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<byte[]> downloadLicenseFile(@PathVariable String appKey,
@AuthenticationPrincipal String tenantId) {
@GetMapping("/{appKey}/config-file")
public ResponseEntity<byte[]> 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<ApiResponse<Map<String, Object>>> parseLicenseFile(
@PostMapping("/config/parse")
public ResponseEntity<ApiResponse<Map<String, Object>>> parseConfigFile(
@RequestBody Map<String, String> 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());
}
}

查看文件

@ -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; }
}

查看文件

@ -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<String, Object> 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<String, Object> 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);
}
}