diff --git a/docs/工具箱端-授权对接指南/utils/AesGcmUtil.kt b/docs/工具箱端-授权对接指南/utils/AesGcmUtil.kt new file mode 100644 index 0000000..1fa2998 --- /dev/null +++ b/docs/工具箱端-授权对接指南/utils/AesGcmUtil.kt @@ -0,0 +1,124 @@ +package top.tangyh.lamp.filing.utils + +import io.github.oshai.kotlinlogging.KotlinLogging +import java.nio.charset.StandardCharsets +import java.util.* +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +private val logger = KotlinLogging.logger {} + +/** + * AES-256-GCM 加密解密工具类 + * + * 安全设计说明: + * - 使用 AES-256-GCM 提供认证加密(AEAD) + * - GCM 模式自动提供认证标签(tag),防止数据被篡改 + * - IV(初始化向量)长度为 12 字节(96位),符合 GCM 推荐 + * - 认证标签长度为 16 字节(128位),提供强认证 + * - 加密数据格式:IV (12字节) + Ciphertext (变长) + Tag (16字节) + * + * 为什么第三方无法伪造: + * - 只有拥有正确 licence + fingerprint 的设备才能派生正确的 AES 密钥 + * - GCM 模式会验证认证标签,任何篡改都会导致解密失败 + * - 即使第三方获取了加密数据,也无法解密(缺少密钥) + */ +object AesGcmUtil { + + private const val ALGORITHM = "AES" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val IV_LENGTH = 12 // 12 bytes = 96 bits (GCM 推荐) + private const val TAG_LENGTH = 16 // 16 bytes = 128 bits (GCM 认证标签长度) + private const val GCM_TAG_LENGTH_BITS = TAG_LENGTH * 8 // 128 bits + + /** + * 解密 AES-256-GCM 加密的数据 + * + * @param encryptedData Base64 编码的加密数据(格式:iv + ciphertext + tag) + * @param key AES 密钥(32字节) + * @return 解密后的明文(UTF-8 字符串) + * @throws RuntimeException 如果解密失败(密钥错误、数据被篡改等) + */ + fun decrypt(encryptedData: String, key: ByteArray): String { + return try { + // 1. Base64 解码 + val encryptedBytes = Base64.getDecoder().decode(encryptedData) + + // 2. 提取 IV、密文和认证标签 + if (encryptedBytes.size < IV_LENGTH + TAG_LENGTH) { + throw IllegalArgumentException("加密数据长度不足,无法提取 IV 和 Tag") + } + + val iv = encryptedBytes.copyOfRange(0, IV_LENGTH) + val tag = encryptedBytes.copyOfRange(encryptedBytes.size - TAG_LENGTH, encryptedBytes.size) + val ciphertext = encryptedBytes.copyOfRange(IV_LENGTH, encryptedBytes.size - TAG_LENGTH) + + // 3. 创建 SecretKeySpec + val secretKey = SecretKeySpec(key, ALGORITHM) + + // 4. 创建 GCMParameterSpec(包含 IV 和认证标签长度) + val gcmSpec = GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv) + + // 5. 初始化 Cipher 进行解密 + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, secretKey, gcmSpec) + + // 6. 执行解密(GCM 模式会自动验证认证标签) + // 如果认证标签验证失败,会抛出异常 + val decryptedBytes = cipher.doFinal(ciphertext + tag) + + // 7. 转换为 UTF-8 字符串 + String(decryptedBytes, StandardCharsets.UTF_8) + } catch (e: javax.crypto.AEADBadTagException) { + logger.error(e) { "AES-GCM 认证标签验证失败,数据可能被篡改或密钥错误" } + throw RuntimeException("解密失败:认证标签验证失败,数据可能被篡改或密钥错误", e) + } catch (e: Exception) { + logger.error(e) { "AES-GCM 解密失败" } + throw RuntimeException("解密失败: ${e.message}", e) + } + } + + /** + * 加密数据(用于测试或客户端实现参考) + * + * @param plaintext 明文数据 + * @param key AES 密钥(32字节) + * @return Base64 编码的加密数据(格式:iv + ciphertext + tag) + */ + fun encrypt(plaintext: String, key: ByteArray): String { + return try { + // 1. 生成随机 IV + val iv = ByteArray(IV_LENGTH) + java.security.SecureRandom().nextBytes(iv) + + // 2. 创建 SecretKeySpec + val secretKey = SecretKeySpec(key, ALGORITHM) + + // 3. 创建 GCMParameterSpec + val gcmSpec = GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv) + + // 4. 初始化 Cipher 进行加密 + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, secretKey, gcmSpec) + + // 5. 执行加密 + val plaintextBytes = plaintext.toByteArray(StandardCharsets.UTF_8) + val encryptedBytes = cipher.doFinal(plaintextBytes) + + // 6. 组装:IV + Ciphertext + Tag + // GCM 模式会将认证标签附加到密文末尾 + val ciphertext = encryptedBytes.copyOfRange(0, encryptedBytes.size - TAG_LENGTH) + val tag = encryptedBytes.copyOfRange(encryptedBytes.size - TAG_LENGTH, encryptedBytes.size) + + val result = iv + ciphertext + tag + + // 7. Base64 编码返回 + Base64.getEncoder().encodeToString(result) + } catch (e: Exception) { + logger.error(e) { "AES-GCM 加密失败" } + throw RuntimeException("加密失败: ${e.message}", e) + } + } +} + diff --git a/docs/工具箱端-授权对接指南/utils/DateUtil.kt b/docs/工具箱端-授权对接指南/utils/DateUtil.kt new file mode 100644 index 0000000..99ac1b7 --- /dev/null +++ b/docs/工具箱端-授权对接指南/utils/DateUtil.kt @@ -0,0 +1,42 @@ +package top.tangyh.lamp.filing.utils + +import java.text.SimpleDateFormat +import java.util.* + +class DateUtil { + + companion object { + // 获取当前时间戳 + fun getCurrentTimestamp(): Long { + return System.currentTimeMillis() + } + + // 格式化日期 + fun formatDate(date: Date, format: String = "yyyy-MM-dd HH:mm:ss"): String { + val sdf = SimpleDateFormat(format) + return sdf.format(date) + } + + // 解析日期字符串 + fun parseDate(dateString: String, format: String = "yyyy-MM-dd HH:mm:ss"): Date? { + val sdf = SimpleDateFormat(format) + return try { + sdf.parse(dateString) + } catch (e: Exception) { + null + } + } + + // 计算两个日期之间的天数差 + fun getDaysBetweenDates(date1: Date, date2: Date): Long { + val diff = Math.abs(date1.time - date2.time) + return diff / (24 * 60 * 60 * 1000) + } + + // 获取当前时间并格式化为 yyyy-MM-dd_HH-mm-ss + fun getCurrentFormattedTime(format: String = "yyyy-MM-dd_HH-mm-ss"): String { + val sdf = SimpleDateFormat(format) + return sdf.format(Date()) + } + } +} \ No newline at end of file diff --git a/docs/工具箱端-授权对接指南/utils/DeviceSignatureUtil.kt b/docs/工具箱端-授权对接指南/utils/DeviceSignatureUtil.kt new file mode 100644 index 0000000..f522fb3 --- /dev/null +++ b/docs/工具箱端-授权对接指南/utils/DeviceSignatureUtil.kt @@ -0,0 +1,129 @@ +package top.tangyh.lamp.filing.utils + +import io.github.oshai.kotlinlogging.KotlinLogging +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.* +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +private val logger = KotlinLogging.logger {} + +/** + * 设备签名工具类 + * 用于生成和验证设备报告签名 + * + * 签名算法:HMAC-SHA256 + * 签名数据(严格顺序): + * sign_payload = taskId + inspectionId + + * SHA256(assets.json) + + * SHA256(vulnerabilities.json) + + * SHA256(weakPasswords.json) + + * SHA256(漏洞评估报告.html) + * + * 安全设计说明: + * - 使用 HMAC-SHA256 提供消息认证,防止伪造和篡改 + * - 签名包含 taskId 和 inspectionId,确保签名与特定任务绑定 + * - 包含多个报告文件的 SHA256,确保报告内容完整性 + * - 只有拥有正确 licence + fingerprint 的设备才能生成有效签名 + */ +object DeviceSignatureUtil { + + private const val HMAC_ALGORITHM = "HmacSHA256" + + /** + * 签名数据文件列表(严格顺序) + */ + data class SignatureFileHashes( + val assetsJsonSha256: String, + val vulnerabilitiesJsonSha256: String, + val weakPasswordsJsonSha256: String, + val reportHtmlSha256: String + ) + + /** + * 生成设备签名 + * + * @param key 派生密钥(32字节) + * @param taskId 任务ID + * @param inspectionId 检查ID + * @param fileHashes 各个文件的 SHA256 哈希值(hex字符串) + * @return Base64 编码的签名 + */ + fun generateSignature( + key: ByteArray, + taskId: String, + inspectionId: Long, + fileHashes: SignatureFileHashes + ): String { + return try { + // 组装签名数据(严格顺序): + // taskId + inspectionId + SHA256(assets.json) + SHA256(vulnerabilities.json) + + // SHA256(weakPasswords.json) + SHA256(漏洞评估报告.html) + val signatureData = buildString { + append(taskId) + append(inspectionId) + append(fileHashes.assetsJsonSha256) + append(fileHashes.vulnerabilitiesJsonSha256) + append(fileHashes.weakPasswordsJsonSha256) + append(fileHashes.reportHtmlSha256) + } + val dataBytes = signatureData.toByteArray(StandardCharsets.UTF_8) + + // 使用 HMAC-SHA256 计算签名 + val mac = Mac.getInstance(HMAC_ALGORITHM) + val secretKey = SecretKeySpec(key, HMAC_ALGORITHM) + mac.init(secretKey) + val signatureBytes = mac.doFinal(dataBytes) + + // Base64 编码返回 + Base64.getEncoder().encodeToString(signatureBytes) + } catch (e: Exception) { + logger.error(e) { "生成设备签名失败: taskId=$taskId, inspectionId=$inspectionId" } + throw RuntimeException("生成设备签名失败: ${e.message}", e) + } + } + + /** + * 验证设备签名 + * + * @param key 派生密钥(32字节) + * @param taskId 任务ID + * @param inspectionId 检查ID + * @param fileHashes 各个文件的 SHA256 哈希值(hex字符串) + * @param expectedSignature Base64 编码的期望签名 + * @return true 如果签名匹配,false 否则 + */ + fun verifySignature( + key: ByteArray, + taskId: String, + inspectionId: Long, + fileHashes: SignatureFileHashes, + expectedSignature: String + ): Boolean { + return try { + val calculatedSignature = generateSignature(key, taskId, inspectionId, fileHashes) + // 使用时间安全的比较,防止时序攻击 + MessageDigest.isEqual( + Base64.getDecoder().decode(expectedSignature), + Base64.getDecoder().decode(calculatedSignature) + ) + } catch (e: Exception) { + logger.error(e) { "验证设备签名失败: taskId=$taskId, inspectionId=$inspectionId" } + false + } + } + + /** + * 计算文件的 SHA256 哈希值(hex字符串) + * + * @param fileContent 文件内容 + * @return SHA256 哈希值的 hex 字符串 + */ + fun calculateSha256(fileContent: ByteArray): String { + val digest = MessageDigest.getInstance("SHA-256") + val hashBytes = digest.digest(fileContent) + return hashBytes.joinToString("") { "%02x".format(it) } + } +} + diff --git a/docs/工具箱端-授权对接指南/utils/DistributedIdUtil.kt b/docs/工具箱端-授权对接指南/utils/DistributedIdUtil.kt new file mode 100644 index 0000000..2517e31 --- /dev/null +++ b/docs/工具箱端-授权对接指南/utils/DistributedIdUtil.kt @@ -0,0 +1,12 @@ +package top.tangyh.lamp.filing.utils + +object DistributedIdUtil { + fun generateId(platformId: Long, localId: Long): Long { + require(platformId in 0..0xFFFF) { "platformId must be 0-65535" } + val safeLocalId = localId and 0xFFFFFFFFFFFF + return (platformId shl 48) or safeLocalId + } + + fun parsePlatform(id: Long): Long = id ushr 48 + fun parseLocal(id: Long): Long = id and 0xFFFFFFFFFFFF +} diff --git a/docs/工具箱端-授权对接指南/utils/HashUtil.kt b/docs/工具箱端-授权对接指南/utils/HashUtil.kt new file mode 100644 index 0000000..11125d6 --- /dev/null +++ b/docs/工具箱端-授权对接指南/utils/HashUtil.kt @@ -0,0 +1,18 @@ +package top.tangyh.lamp.filing.utils + +import java.io.InputStream +import java.security.MessageDigest + +object HashUtil { + fun calculateFileHash(inputStream: InputStream): String { + val digest = MessageDigest.getInstance("SHA-256") + val buffer = ByteArray(8192) + var bytesRead: Int + + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + digest.update(buffer, 0, bytesRead) + } + + return digest.digest().joinToString("") { "%02x".format(it) } + } +} \ No newline at end of file diff --git a/docs/工具箱端-授权对接指南/utils/HkdfUtil.kt b/docs/工具箱端-授权对接指南/utils/HkdfUtil.kt new file mode 100644 index 0000000..7e1c860 --- /dev/null +++ b/docs/工具箱端-授权对接指南/utils/HkdfUtil.kt @@ -0,0 +1,66 @@ +package top.tangyh.lamp.filing.utils + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.bouncycastle.crypto.digests.SHA256Digest +import org.bouncycastle.crypto.generators.HKDFBytesGenerator +import org.bouncycastle.crypto.params.HKDFParameters +import java.nio.charset.StandardCharsets + +private val logger = KotlinLogging.logger {} + +/** + * HKDF (HMAC-based Key Derivation Function) 工具类 + * 用于从 licence + fingerprint 派生设备签名密钥 + * + * 安全设计说明: + * - 使用 HKDF 而非直接哈希,提供更好的密钥分离和扩展性 + * - Salt 固定为 "AUTH_V3_SALT",确保同一输入产生相同密钥 + * - Info 参数用于区分不同用途的密钥派生(device_report_signature) + * - 输出长度 32 字节(256位),适用于 HMAC-SHA256 + */ +object HkdfUtil { + + private const val SALT = "AUTH_V3_SALT" + private const val INFO = "device_report_signature" + private const val KEY_LENGTH = 32 // 32 bytes = 256 bits + + /** + * 使用 HKDF 派生密钥(使用默认 salt 和 info) + * + * @param input 输入密钥材料(licence + fingerprint) + * @return 派生出的密钥(32字节) + */ + fun deriveKey(input: String): ByteArray { + return deriveKey(input, SALT, INFO) + } + + /** + * 使用 HKDF 派生密钥(支持自定义 salt 和 info) + * + * @param input 输入密钥材料(licence + fingerprint) + * @param salt Salt 值(用于密钥派生) + * @param info Info 值(用于区分不同用途的密钥) + * @param keyLength 输出密钥长度(默认32字节) + * @return 派生出的密钥 + */ + fun deriveKey(input: String, salt: String, info: String, keyLength: Int = KEY_LENGTH): ByteArray { + return try { + val inputBytes = input.toByteArray(StandardCharsets.UTF_8) + val saltBytes = salt.toByteArray(StandardCharsets.UTF_8) + val infoBytes = info.toByteArray(StandardCharsets.UTF_8) + + val hkdf = HKDFBytesGenerator(SHA256Digest()) + val params = HKDFParameters(inputBytes, saltBytes, infoBytes) + hkdf.init(params) + + val derivedKey = ByteArray(keyLength) + hkdf.generateBytes(derivedKey, 0, keyLength) + + derivedKey + } catch (e: Exception) { + logger.error(e) { "HKDF 密钥派生失败: input=$input, salt=$salt, info=$info" } + throw RuntimeException("HKDF 密钥派生失败: ${e.message}", e) + } + } +} + diff --git a/docs/工具箱端-授权对接指南/utils/JwtUtil.kt b/docs/工具箱端-授权对接指南/utils/JwtUtil.kt new file mode 100644 index 0000000..4757e13 --- /dev/null +++ b/docs/工具箱端-授权对接指南/utils/JwtUtil.kt @@ -0,0 +1,50 @@ +package top.tangyh.lamp.filing.utils + +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.SignatureAlgorithm +import io.jsonwebtoken.security.Keys +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.* +import javax.crypto.SecretKey + +@Component +class JwtUtil( + @Value("\${jwt.secret}") + private val secretKey: String +) { + + + // 生成签名 Key(HS256) + private val signingKey: SecretKey = Keys.hmacShaKeyFor(Base64.getDecoder().decode(secretKey)) + + /** + * 生成 Token + * */ + fun generateToken(subject: String, claims: Map = emptyMap(), expireDays: Long = 7): String { + val now = LocalDateTime.now() + val expiration = now.plusDays(expireDays) + + return Jwts.builder() + .setSubject(subject) + .setClaims(claims) + .setIssuedAt(Date.from(now.atZone(ZoneId.systemDefault()).toInstant())) + .setExpiration(Date.from(expiration.atZone(ZoneId.systemDefault()).toInstant())) + .signWith(signingKey, SignatureAlgorithm.HS256) + .compact() + } + + + /** + * 解析 Token 获取 Claims + */ + fun parseToken(token: String): Map { + return Jwts.parserBuilder() + .setSigningKey(signingKey) + .build() + .parseClaimsJws(token) + .body + } +} diff --git a/docs/工具箱端-授权对接指南/utils/RegionUtil.kt b/docs/工具箱端-授权对接指南/utils/RegionUtil.kt new file mode 100644 index 0000000..c078cdb --- /dev/null +++ b/docs/工具箱端-授权对接指南/utils/RegionUtil.kt @@ -0,0 +1,22 @@ +package top.tangyh.lamp.filing.utils + +import kotlin.text.substring + +object RegionUtil { + fun getLevel(code: String?): String { + if (code == null || code.length != 6) { + return "无效编码" + } + + val province = code.substring(0, 2) + val city = code.substring(2, 4) + val county = code.substring(4, 6) + + return when { + city == "00" && county == "00" -> "province" + city != "00" && county == "00" -> "city" + county != "00" -> "county" + else -> "未知级别" + } + } +} diff --git a/docs/工具箱端-授权对接指南/utils/RsaOaepDecryptionUtil.kt b/docs/工具箱端-授权对接指南/utils/RsaOaepDecryptionUtil.kt new file mode 100644 index 0000000..2d7397f --- /dev/null +++ b/docs/工具箱端-授权对接指南/utils/RsaOaepDecryptionUtil.kt @@ -0,0 +1,115 @@ +package top.tangyh.lamp.filing.utils + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.nio.charset.StandardCharsets +import java.security.KeyFactory +import java.security.PublicKey +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.X509EncodedKeySpec +import java.util.* +import javax.crypto.Cipher + +private val logger = KotlinLogging.logger {} + +/** + * RSA-OAEP 解密工具类 + * 用于设备身份首次绑定时解密设备信息 + * + * 使用场景:设备使用平台的公钥加密数据,平台使用私钥解密 + */ +@Component +class RsaOaepDecryptionUtil( + @Value("\${device.encrypt.privateKey:}") + private val privateKeyBase64: String +) { + + private val keyFactory = KeyFactory.getInstance("RSA") + private val cipherAlgorithm = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding" + + // 缓存私钥,避免每次解密都重新加载 + private val privateKey by lazy { + if (privateKeyBase64.isBlank()) { + throw IllegalStateException("RSA私钥未配置,无法解密设备信息") + } + val privateKeyBytes = Base64.getDecoder().decode(privateKeyBase64) + val keySpec = PKCS8EncodedKeySpec(privateKeyBytes) + keyFactory.generatePrivate(keySpec) + } + + init { + if (privateKeyBase64.isBlank()) { + logger.warn { "RSA私钥未配置,设备授权解密功能可能无法使用" } + } + } + + /** + * 使用RSA-OAEP解密设备信息 + * @param encryptedData Base64编码的加密数据 + * @return 解密后的JSON字符串 + */ + fun decrypt(encryptedData: String): String { + if (privateKeyBase64.isBlank()) { + throw IllegalStateException("RSA私钥未配置,无法解密设备信息") + } + + return try { + // 创建新的Cipher实例(Cipher不是线程安全的) + val cipher = Cipher.getInstance(cipherAlgorithm) + + // 初始化解密器 + cipher.init(Cipher.DECRYPT_MODE, privateKey) + + // Base64解码加密数据 + val encryptedBytes = Base64.getDecoder().decode(encryptedData) + + // 解密数据 + val decryptedBytes = cipher.doFinal(encryptedBytes) + + // 返回解密后的字符串 + String(decryptedBytes, StandardCharsets.UTF_8) + } catch (e: Exception) { + logger.error(e) { "RSA-OAEP解密设备信息失败" } + throw RuntimeException("RSA-OAEP解密设备信息失败: ${e.message}", e) + } + } + + + /** + * 使用平台公钥加密数据 + * + * @param plainText 原始JSON字符串(设备信息) + * @param publicKeyBase64 平台公钥(Base64) + * @return Base64编码的密文 + */ + fun encrypt1( + plainText: String, + publicKeyBase64: String + ): String { + try { + val publicKey = loadPublicKey(publicKeyBase64) + + val cipher = Cipher.getInstance(cipherAlgorithm) + cipher.init(Cipher.ENCRYPT_MODE, publicKey) + + val encryptedBytes = cipher.doFinal( + plainText.toByteArray(StandardCharsets.UTF_8) + ) + + return Base64.getEncoder().encodeToString(encryptedBytes) + } catch (e: Exception) { + logger.error(e) { "RSA-OAEP 加密失败" } + throw RuntimeException("RSA-OAEP 加密失败: ${e.message}", e) + } + } + + private fun loadPublicKey(base64Key: String): PublicKey { + val keyBytes = Base64.getDecoder().decode(base64Key) + val keySpec = X509EncodedKeySpec(keyBytes) + return keyFactory.generatePublic(keySpec) + } + +} + + diff --git a/docs/工具箱端-授权对接指南/utils/RsaOaepDecryptionUtilV2.kt b/docs/工具箱端-授权对接指南/utils/RsaOaepDecryptionUtilV2.kt new file mode 100644 index 0000000..1de4781 --- /dev/null +++ b/docs/工具箱端-授权对接指南/utils/RsaOaepDecryptionUtilV2.kt @@ -0,0 +1,103 @@ +package top.tangyh.lamp.filing.utils + +import java.nio.charset.StandardCharsets +import java.security.KeyFactory +import java.security.KeyPairGenerator +import java.security.PublicKey +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.X509EncodedKeySpec +import java.util.* +import javax.crypto.Cipher + + +object RsaOaepCryptoUtil { + + private const val cipherAlgorithm = + "RSA/ECB/OAEPWithSHA-256AndMGF1Padding" + + private val keyFactory = KeyFactory.getInstance("RSA") + + fun encrypt( + plainText: String, + publicKeyBase64: String + ): String { + val publicKey = loadPublicKey(publicKeyBase64) + + val cipher = Cipher.getInstance(cipherAlgorithm) + cipher.init(Cipher.ENCRYPT_MODE, publicKey) + + val encryptedBytes = cipher.doFinal( + plainText.toByteArray(StandardCharsets.UTF_8) + ) + return Base64.getEncoder().encodeToString(encryptedBytes) + } + + fun decrypt( + encryptedData: String, + privateKeyBase64: String + ): String { + val privateKeyBytes = Base64.getDecoder().decode(privateKeyBase64) + val keySpec = PKCS8EncodedKeySpec(privateKeyBytes) + val privateKey = keyFactory.generatePrivate(keySpec) + + val cipher = Cipher.getInstance(cipherAlgorithm) + cipher.init(Cipher.DECRYPT_MODE, privateKey) + + val decryptedBytes = cipher.doFinal( + Base64.getDecoder().decode(encryptedData) + ) + return String(decryptedBytes, StandardCharsets.UTF_8) + } + + private fun loadPublicKey(base64Key: String): PublicKey { + val keyBytes = Base64.getDecoder().decode(base64Key) + val keySpec = X509EncodedKeySpec(keyBytes) + return keyFactory.generatePublic(keySpec) + } +} + + +object Test { + @JvmStatic + fun main(args: Array) { + val keyPairGenerator = KeyPairGenerator.getInstance("RSA") + keyPairGenerator.initialize(2048) + val keyPair = keyPairGenerator.generateKeyPair() + +// val publicKey = Base64.getEncoder().encodeToString(keyPair.public.encoded) +// val privateKey = Base64.getEncoder().encodeToString(keyPair.private.encoded) + + val publicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzDlZvMDVaL+fjl05Hi182JOAUAaN4gh9rOF+1NhKfO4J6e0HLy8lBuylp3A4xoTiyUejNm22h0dqAgDSPnY/xZR76POFTD1soHr2LaFCN8JAbQ96P8gE7wC9qpoTssVvIVRH7QbVd260J6eD0Szwcx9cg591RSN69pMpe5IVRi8T99Hhql6/wnZHORPr18eESLOY93jRskLzc0q18r68RRoTJiQf+9YC8ub5iKp7rCjVnPi1UbIYmXmL08tk5mksYA0NqWQAa1ofKxx/9tQtB9uTjhTxuTu94XU9jlGU87qaHZs+kpqa8CAbYYJFbSP1xHwoZzpU2jpw2aF22HBYxwIDAQAB" + val privateKey = "MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDMOVm8wNVov5+OXTkeLXzYk4BQBo3iCH2s4X7U2Ep87gnp7QcvLyUG7KWncDjGhOLJR6M2bbaHR2oCANI+dj/FlHvo84VMPWygevYtoUI3wkBtD3o/yATvAL2qmhOyxW8hVEftBtV3brQnp4PRLPBzH1yDn3VFI3r2kyl7khVGLxP30eGqXr/Cdkc5E+vXx4RIs5j3eNGyQvNzSrXyvrxFGhMmJB/71gLy5vmIqnusKNWc+LVRshiZeYvTy2TmaSxgDQ2pZABrWh8rHH/21C0H25OOFPG5O73hdT2OUZTzupodmz6SmprwIBthgkVtI/XEfChnOlTaOnDZoXbYcFjHAgMBAAECggEBAKUDagjj3wwmWWwU00QZCRmOBU2kHhyXM8Tu5yZgJb/QLt5/ESEE/OwdZrRmLtnpIb31TmF6QNQJ1hQMSdUCgwDIu1489mXl89GvyR6RgAnBwYDd0q+alLHxbU9d87CtauAJU5ynuvAn2RV5ez8XCqpamswXmg/lXUPDIO+h1+K+UJPwYFhzHL3WpZQXHofcSUCRn0JONbz7c9OwVFYe0v1g18wwAvIAsEEimcnXFZaIJ/md2md0Jk4+LOFUsbgCM5skNTooXarDiHGCRFMJXxvYpavC6hhhcWfh3K2ydHMhFdF70ZOs169ShDtYq4i/hkaZ+p7kFVo8Z6oxVUpC8IECgYEA+OQxfc2iGMD9uus/r1yjUx0kdhYKik202tE53C4ikAn490+Lb8qcY/kykFj721MHBc73ijq/ZNU21JxyMx4T1jbdvl+5Kv9EUFAQmsJuTSudC1Dud3MlUgYEtVUhau2WOexUIRyCda6V4NUYkJG4vLRyMloPprF3xThmJRgG9qcCgYEA0g6QrU1e/DjADSpymRfqoOX4ASp7G++CRHTB2NZvA6ZxnWoEX59TO3mW4z4fgJkIwevkPUA9nfO2bRQWph+aom5qAczsPmZ2OpR9cjel63AIV1AboUV2Gr/H4IfHs7qGX7JWmW1SnZyvvGuI5MdBHEcysJO+L5V1OVyFRzvj8OECgYAO21A4+jVa1OpQZgp/JUB6jZrHkbk/WDQbe7HAeuCFSJMb8BuaqLV9IjrqcuVVyjb5Gcmc7rTOCAwl1NDcTEdS2iOSYZRkBKjHQoA7PK/o21mce1BAwRbRNprBWDuObnAxNPIwp8sBy1IXAaFdv9UPLpZCey3D/YPwudUfEbgYsQKBgQCZax3sFYh8ew56Dzin7Dnnzk72uwozexkP2p8COovWhKiSqi4LkRh/Zez4iBUGHb+xsxJ+Uf8u8CObQ4LPTmHopPAz5HHfmYJcgrukwlQiwy60ZsPnZA5AtzXLHiCTenZOSrjJUnl2uEv6OChBv+4kMzQol5/erTBy9so5Htr6wQKBgQC3apeFD/x+0FjABleVITzGyAWj/Kxl/OOiL4dQAYW49wVD/j0ujG3CvbK0GHZFwy/Ju+pWlHISbiZiKYko/4GBtp+JwxG9fFmbHdl4BZSTwfQMYdNq8hN+0hBfWwcceIhbEZcFLIvG/ZhkcT3yh874XRn1A5V/AR8W9YFH1EWYwQ==" + +// val plainText = "{\n" + +// " \"taskId\": 723047797139586052,\n" + +// " \"licence\": \"LIC-8F2A-XXXX\",\n" + +// " \"fingerprint\": \"FP-2c91e9f3\",\n" + +// " \"enterpriseId\": \"1173040813421105152\",\n" + +// " \"inspectionId\": \"702286470691215417\",\n" + +// " \"summary\": \"1\"\n" + +// "}" + + val plainText = "{\n" + + " \"licence\": \"lic-1234567890\",\n" + + " \"fingerprint\": \"e19c60d21c544c1118e3b633eae1bb935e2762ebddbc671b60b8b61c65c05d1c\"\n" + + "}" + + val encryptedText = + RsaOaepCryptoUtil.encrypt(plainText, publicKey) + + val decryptedText = + RsaOaepCryptoUtil.decrypt(encryptedText, privateKey) + + println("Plain Text: $plainText") + + println("Public Key: $publicKey") + println("Private Key: $privateKey") + + + println("Encrypted: $encryptedText") + println("Decrypted: $decryptedText") + } +} + diff --git a/docs/工具箱端-授权对接指南/utils/TaskEncryptionUtil.kt b/docs/工具箱端-授权对接指南/utils/TaskEncryptionUtil.kt new file mode 100644 index 0000000..bd138b8 --- /dev/null +++ b/docs/工具箱端-授权对接指南/utils/TaskEncryptionUtil.kt @@ -0,0 +1,120 @@ +package top.tangyh.lamp.filing.utils + +import io.github.oshai.kotlinlogging.KotlinLogging +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.* +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +private val logger = KotlinLogging.logger {} + +/** + * 任务加密工具类 + * 使用 licence + fingerprint 作为密钥对任务数据进行 AES-256-GCM 对称加密 + * + * GCM 模式提供认证加密,比 ECB 模式更安全 + * 加密数据格式:IV(12字节) + 加密数据 + 认证标签(16字节) + */ +object TaskEncryptionUtil { + + private const val ALGORITHM = "AES" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val GCM_IV_LENGTH = 12 // GCM 推荐使用 12 字节 IV + private const val GCM_TAG_LENGTH = 16 // GCM 认证标签长度(128位) + private const val KEY_LENGTH = 32 // AES-256 密钥长度(256位 = 32字节) + + private val secureRandom = SecureRandom() + + /** + * 使用 licence + fingerprint 加密任务数据(AES-256-GCM) + * @param data 待加密的数据(JSON字符串) + * @param licence 授权码 + * @param fingerprint 硬件指纹 + * @return Base64编码的加密数据(包含IV + 加密数据 + 认证标签) + */ + fun encrypt(data: String, licence: String, fingerprint: String): String { + return try { + // 使用 licence + fingerprint 生成密钥 + val key = generateKey(licence, fingerprint) + + // 生成随机 IV(12字节) + val iv = ByteArray(GCM_IV_LENGTH) + secureRandom.nextBytes(iv) + + // 创建加密器 + val cipher = Cipher.getInstance(TRANSFORMATION) + val parameterSpec = GCMParameterSpec(GCM_TAG_LENGTH * 8, iv) // 标签长度以位为单位 + cipher.init(Cipher.ENCRYPT_MODE, key, parameterSpec) + + // 加密数据 + val encryptedBytes = cipher.doFinal(data.toByteArray(StandardCharsets.UTF_8)) + + // 组合:IV + 加密数据(包含认证标签) + val combined = ByteArray(iv.size + encryptedBytes.size) + System.arraycopy(iv, 0, combined, 0, iv.size) + System.arraycopy(encryptedBytes, 0, combined, iv.size, encryptedBytes.size) + + // 返回 Base64 编码的加密数据 + Base64.getEncoder().encodeToString(combined) + } catch (e: Exception) { + logger.error(e) { "AES-256-GCM加密任务数据失败" } + throw RuntimeException("加密任务数据失败: ${e.message}", e) + } + } + + /** + * 使用 licence + fingerprint 解密任务数据(AES-256-GCM) + * @param encryptedData Base64编码的加密数据(包含IV + 加密数据 + 认证标签) + * @param licence 授权码 + * @param fingerprint 硬件指纹 + * @return 解密后的数据(JSON字符串) + */ + fun decrypt(encryptedData: String, licence: String, fingerprint: String): String { + return try { + // 使用 licence + fingerprint 生成密钥 + val key = generateKey(licence, fingerprint) + + // Base64 解码 + val combined = Base64.getDecoder().decode(encryptedData) + + // 分离 IV 和加密数据 + if (combined.size < GCM_IV_LENGTH) { + throw IllegalArgumentException("加密数据格式错误:数据长度不足") + } + + val iv = combined.sliceArray(0 until GCM_IV_LENGTH) + val cipherText = combined.sliceArray(GCM_IV_LENGTH until combined.size) + + // 创建解密器 + val cipher = Cipher.getInstance(TRANSFORMATION) + val parameterSpec = GCMParameterSpec(GCM_TAG_LENGTH * 8, iv) + cipher.init(Cipher.DECRYPT_MODE, key, parameterSpec) + + // 解密数据(GCM 会自动验证认证标签) + val decryptedBytes = cipher.doFinal(cipherText) + + // 返回解密后的字符串 + String(decryptedBytes, StandardCharsets.UTF_8) + } catch (e: Exception) { + logger.error(e) { "AES-256-GCM解密任务数据失败" } + throw RuntimeException("解密任务数据失败: ${e.message}", e) + } + } + + /** + * 使用 licence + fingerprint 生成 AES-256 密钥(256位 = 32字节) + * 使用 SHA-256 哈希的全部32字节作为密钥 + */ + private fun generateKey(licence: String, fingerprint: String): SecretKeySpec { + val combined = "$licence$fingerprint" + val digest = MessageDigest.getInstance("SHA-256") + val hash = digest.digest(combined.toByteArray(StandardCharsets.UTF_8)) + + // 使用全部32字节作为 AES-256 密钥 + return SecretKeySpec(hash, ALGORITHM) + } +} + diff --git a/docs/工具箱端-授权对接指南/utils/ZipVerifierUtil.kt b/docs/工具箱端-授权对接指南/utils/ZipVerifierUtil.kt new file mode 100644 index 0000000..c5c567a --- /dev/null +++ b/docs/工具箱端-授权对接指南/utils/ZipVerifierUtil.kt @@ -0,0 +1,134 @@ +package top.tangyh.lamp.filing.utils + +import com.fasterxml.jackson.databind.ObjectMapper +import io.github.oshai.kotlinlogging.KotlinLogging +import org.bouncycastle.openpgp.* +import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator +import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentVerifierBuilderProvider +import java.io.ByteArrayInputStream +import java.io.InputStream +import java.security.MessageDigest +import java.security.Security +import java.util.zip.ZipFile + +object ZipVerifierUtil { + + private val logger = KotlinLogging.logger {} + +// @JvmStatic +// fun main(args: Array) { +// verifyZip("signed.zip", "public.key") +// } + + /** + * 验证 ZIP 文件 + */ + @Throws(Exception::class) + fun verifyZip(zipPath: String, pubkeyContent: String):Boolean { + + println(Security.getProviders().joinToString { it.name }) + val publicKey = readPublicKey( + ByteArrayInputStream(pubkeyContent.toByteArray()) + ) + + val zip = ZipFile(zipPath) + + // 1. 读取 manifest.json + val manifestEntry = zip.getEntry("META-INF/manifest.json") + ?: throw RuntimeException("manifest.json is missing!") + val manifestJson = zip.getInputStream(manifestEntry).readAllBytes().toString(Charsets.UTF_8) + + // 2. 读取 signature.asc + val sigEntry = zip.getEntry("META-INF/signature.asc") + ?: throw RuntimeException("signature.asc is missing!") + val signature = zip.getInputStream(sigEntry).readAllBytes() + + // 3. 使用 OpenPGP 验证签名 + val ok = verifyDetachedSignature(publicKey, manifestJson.toByteArray(), signature) + if (!ok) throw RuntimeException("PGP signature invalid!") + + // 4. 校验 manifest 里每个文件的 SHA-256 + val mapper = ObjectMapper() + val manifest = mapper.readValue(manifestJson, Map::class.java) + val files = manifest["files"] as? Map + ?: throw RuntimeException("Invalid manifest.json: missing 'files'") + + for ((name, expectedHash) in files) { + val entry = zip.getEntry(name) + ?: throw RuntimeException("文件不存在: $name") + + val data = zip.getInputStream(entry).readAllBytes() + val hash = sha256Hex(data) + + if (!hash.equals(expectedHash, ignoreCase = true)) { + throw RuntimeException("Hash mismatch: $name") + } + } + return true + } + + @Throws(Exception::class) + private fun sha256Hex(data: ByteArray): String { + val md = MessageDigest.getInstance("SHA-256") + return bytesToHex(md.digest(data)) + } + + private fun bytesToHex(bytes: ByteArray): String { + return bytes.joinToString("") { "%02x".format(it) } + } + + @Throws(Exception::class) + private fun readPublicKey(keyIn: InputStream): PGPPublicKey { + val keyRings = PGPPublicKeyRingCollection( + PGPUtil.getDecoderStream(keyIn), + JcaKeyFingerprintCalculator() + ) + + for (keyRing in keyRings) { + for (key in keyRing) { + if (key.isEncryptionKey || key.isMasterKey) { + return key + } + } + } + + throw IllegalArgumentException("Can't find public key") + } + + @Throws(Exception::class) + private fun verifyDetachedSignature( + key: PGPPublicKey, + data: ByteArray, + sigBytes: ByteArray + ): Boolean { + + val decoder = PGPUtil.getDecoderStream(ByteArrayInputStream(sigBytes)) + val factory = PGPObjectFactory(decoder, JcaKeyFingerprintCalculator()) + + val message = factory.nextObject() + ?: throw IllegalArgumentException("Invalid signature file") + + val sigList = when (message) { + is PGPSignatureList -> message + is PGPCompressedData -> { + val compressedFactory = PGPObjectFactory( + message.dataStream, + JcaKeyFingerprintCalculator() + ) + val compressedObj = compressedFactory.nextObject() + compressedObj as? PGPSignatureList + ?: throw IllegalArgumentException("Invalid PGP signature (not signature list)") + } + else -> + throw IllegalArgumentException("Unsupported PGP signature format: ${message::class.java}") + } + + val sig = sigList[0] + + sig.init(JcaPGPContentVerifierBuilderProvider().setProvider("BC"), key) + sig.update(data) + + return sig.verify() + } + +}