跳转至

apps\iot\src\main\java\cn\nfarms\iot\ota\Ota.kt

源码路径:apps\iot\src\main\java\cn\nfarms\iot\ota\Ota.kt · 174 行

package cn.nfarms.iot.ota

import cn.nfarms.core.secure.Crypto
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.Signature
import java.util.Base64
import kotlinx.serialization.Serializable

/**
 * 固件升级包。
 *
 * ```text
 * OtaPackage = {
 *   version : 目标版本号(单调递增,拒绝降级)
 *   target  : 机型/硬件版本(拒绝刷错机器)
 *   payload : 固件字节(Base64)
 *   sha256  : payload 的摘要
 *   sigB64  : 对 "version|target|sha256" 的 ECDSA 签名
 *   keyId   : 签名密钥标识(便于轮换)
 * }
 * ```
 *
 * # 三条验证顺序不能换
 *
 * 1. **先验签名**:签名不对,后面所有检查都无意义 —— 数据本身就是伪造的;
 * 2. **再对摘要**:签名对但摘要不符 = 内容被改过(或签名被挪用到别的包上);
 * 3. **最后才是版本/机型**:这两项是业务规则,不通过只说明"不该装",不是"被篡改"。
 *
 * 顺序反了会有一个具体后果:先比版本再验签,攻击者只要把 version 改大就能绕过
 * 版本检查(签名验不过,但那时你已经把包当成"合法候选"了)。
 */
@Serializable
data class OtaPackage(
    val version: String,
    val target: String,
    val payloadB64: String,
    val sha256: String,
    val sigB64: String,
    val keyId: String = "demo-fw-1",
    val releasedAt: Long = System.currentTimeMillis(),
    val notes: String = "",
)

sealed interface OtaVerdict {
    data object Ok : OtaVerdict
    data class Rejected(val why: String, val stage: Stage) : OtaVerdict

    enum class Stage { Signature, Digest, Version, Target, Size }
}

/**
 * 验签 + 版本判定。
 *
 * 演示里密钥对在本地生成(扮演"厂商签名服务器");真机固件包里应当内置
 * **厂商公钥**的指纹,并把公钥编译进只读资源 —— 决不能从服务器现拉,
 * 那样攻击者换掉服务器就能换掉公钥。
 */
class OtaVerifier(private val publicKeyDer: ByteArray, private val keyId: String = "demo-fw-1") {

    fun verify(pkg: OtaPackage, currentVersion: String, deviceTarget: String): OtaVerdict {
        // 1. 签名
        val signed = "${pkg.version}|${pkg.target}|${pkg.sha256}"
        val sigOk = Crypto.verify(
            publicKeyDer = publicKeyDer,
            data = signed.toByteArray(Charsets.UTF_8),
            signature = runCatching { Base64.getDecoder().decode(pkg.sigB64) }.getOrNull()
                ?: return OtaVerdict.Rejected("签名不是合法的 Base64", OtaVerdict.Stage.Signature),
            signatureAlgorithm = "SHA256withECDSA",
            keyAlgorithm = "EC",
        )
        if (!sigOk) return OtaVerdict.Rejected("签名无效 —— 这个包不是厂商签的", OtaVerdict.Stage.Signature)
        if (pkg.keyId != keyId) return OtaVerdict.Rejected("keyId 不匹配(${pkg.keyId})", OtaVerdict.Stage.Signature)

        // 2. 摘要
        val payload = runCatching { Base64.getDecoder().decode(pkg.payloadB64) }.getOrNull()
            ?: return OtaVerdict.Rejected("固件内容不是合法 Base64", OtaVerdict.Stage.Digest)
        if (payload.isEmpty()) return OtaVerdict.Rejected("固件是空的", OtaVerdict.Stage.Digest)
        val actual = Crypto.sha256Hex(payload)
        if (!actual.equals(pkg.sha256, ignoreCase = true)) {
            return OtaVerdict.Rejected("SHA-256 不符:期望 ${pkg.sha256.take(16)}… 实际 ${actual.take(16)}…", OtaVerdict.Stage.Digest)
        }

        // 3. 机型
        if (pkg.target != deviceTarget) {
            return OtaVerdict.Rejected("机型不符:包是 ${pkg.target},设备是 $deviceTarget", OtaVerdict.Stage.Target)
        }

        // 4. 版本:只升不降
        val cur = parseVersion(currentVersion)
        val tgt = parseVersion(pkg.version)
        if (tgt <= cur) {
            return OtaVerdict.Rejected("版本不高于当前($currentVersion → ${pkg.version})", OtaVerdict.Stage.Version)
        }

        return OtaVerdict.Ok
    }

    /** `1.4.10` 这种必须按**数字段**比,不能按字符串比(`"1.4.10" < "1.4.9"` 是字符串序的陷阱)。 */
    fun parseVersion(v: String): List<Int> =
        v.split('.').map { it.filter(Char::isDigit).toIntOrNull() ?: 0 } +
            List(maxOf(0, 4 - v.split('.').size)) { 0 }

    private operator fun List<Int>.compareTo(other: List<Int>): Int {
        val n = maxOf(size, other.size)
        for (i in 0 until n) {
            val a = getOrElse(i) { 0 }
            val b = other.getOrElse(i) { 0 }
            if (a != b) return a - b
        }
        return 0
    }
}

/**
 * 演示固件包工厂。
 *
 * 生成一份**真的能被验签**的包(ECDSA P-256 + SHA-256),
 * 同时能生成三种"坏包"用来验证拒绝路径:
 *  - 改内容不改签名;
 *  - 用一个不同密钥签;
 *  - 版本号倒退。
 */
class FirmwareFactory {
    val keys: KeyPair = KeyPairGenerator.getInstance("EC").apply { initialize(256) }.generateKeyPair()

    fun make(
        version: String,
        target: String = "NF-Demo-01",
        bytes: ByteArray = ByteArray(48 * 1024) { (it * 7 + 13).toByte() },
        notes: String = "演示固件",
    ): OtaPackage {
        val sha = Crypto.sha256Hex(bytes)
        val signed = "$version|$target|$sha"
        val sig = Signature.getInstance("SHA256withECDSA").run {
            initSign(keys.private)
            update(signed.toByteArray(Charsets.UTF_8))
            sign()
        }
        return OtaPackage(
            version = version,
            target = target,
            payloadB64 = Base64.getEncoder().encodeToString(bytes),
            sha256 = sha,
            sigB64 = Base64.getEncoder().encodeToString(sig),
            notes = notes,
        )
    }

    /** 坏包 1:改了内容(签名还是原来那份)。 */
    fun tamperedContent(pkg: OtaPackage): OtaPackage {
        val bytes = Base64.getDecoder().decode(pkg.payloadB64)
        bytes[100] = (bytes[100] + 1).toByte()
        return pkg.copy(payloadB64 = Base64.getEncoder().encodeToString(bytes))
    }

    /** 坏包 2:换个密钥签。 */
    fun signedByOtherKey(pkg: OtaPackage): OtaPackage {
        val other = KeyPairGenerator.getInstance("EC").apply { initialize(256) }.generateKeyPair()
        val signed = "${pkg.version}|${pkg.target}|${pkg.sha256}"
        val sig = Signature.getInstance("SHA256withECDSA").run {
            initSign(other.private)
            update(signed.toByteArray(Charsets.UTF_8))
            sign()
        }
        return pkg.copy(sigB64 = Base64.getEncoder().encodeToString(sig))
    }

    /** 坏包 3:版本倒退。 */
    fun downgrade(pkg: OtaPackage): OtaPackage = make(version = "0.9.0", target = pkg.target)

    fun publicKeyDer(): ByteArray = keys.public.encoded
}