LicenseCodec.kt

4.4 KB · raw

1package com.peachpasswords.license23import java.math.BigInteger4import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters5import org.bouncycastle.crypto.signers.Ed25519Signer67/**8 * Pure licence decoding and verification.9 *10 * Split out of `LicenseRepository` so the validation boundary — the one write11 * path whose previous defect was a missing validation call — is testable without12 * Android's EncryptedSharedPreferences (PB-26-147).13 *14 * [publicKeyHex] defaults to the compiled-in key, which is what every production15 * caller uses. It is a parameter so a test can sign a blob with its own keypair16 * and prove the verifier rejects a tampered blob rather than rejecting17 * everything; nothing else may vary it.18 */19internal object LicenseCodec {20    private const val PUBLIC_KEY_HEX =21        "c7dc52d2c484696cf8566dbe6cfe69025d114cb47f6cf53c25088ecfd539bdfa"2223    private const val BASE58_ALPHABET =24        "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"2526    // A 76-byte payload encodes to at most 104 base58 characters.27    private const val MAX_BASE58_LENGTH = 1042829    const val PAYLOAD_SIZE = 1230    const val SIGNATURE_SIZE = 6431    const val TOTAL_SIZE = 763233    const val PRODUCT_TAG = 0x01.toByte()34    const val PLAN_TAG = 0x01.toByte()35    const val VERSION_TAG = 0x01.toByte()3637    /** True when [licenseKey] decodes to a correctly tagged, validly signed payload. */38    fun validate(licenseKey: String, publicKeyHex: String = PUBLIC_KEY_HEX): Boolean {39        return try {40            val decoded = decodeLicenseKey(licenseKey) ?: return false41            if (decoded.size != TOTAL_SIZE) return false42            val payload = decoded.copyOfRange(0, PAYLOAD_SIZE)43            val signature = decoded.copyOfRange(PAYLOAD_SIZE, TOTAL_SIZE)44            // Tag checks first: a blob for another product/plan/version must not45            // reach the signature work at all.46            if (payload[0] != PRODUCT_TAG) return false47            if (payload[1] != PLAN_TAG) return false48            if (payload[2] != VERSION_TAG) return false49            verifySignature(payload, signature, publicKeyHex)50        } catch (_: Exception) {51            false52        }53    }5455    /** Days-since-epoch issue date, or null when the blob is not a valid licence shape. */56    fun issuedAtMillis(licenseKey: String): Long? {57        val decoded = decodeLicenseKey(licenseKey) ?: return null58        if (decoded.size != TOTAL_SIZE) return null59        val payload = decoded.copyOfRange(0, PAYLOAD_SIZE)60        if (payload[0] != PRODUCT_TAG || payload[1] != PLAN_TAG || payload[2] != VERSION_TAG) return null61        val days = (payload[3].toInt() and 0xff) or62            ((payload[4].toInt() and 0xff) shl 8) or63            ((payload[5].toInt() and 0xff) shl 16) or64            ((payload[6].toInt() and 0xff) shl 24)65        return days.toLong() * 86_400_000L66    }6768    private fun decodeLicenseKey(key: String): ByteArray? {69        if (!key.startsWith("PPK-")) return null70        val encoded = key.removePrefix("PPK-").replace("-", "")71        return base58Decode(encoded)72    }7374    private fun base58Decode(input: String): ByteArray? {75        if (input.length > MAX_BASE58_LENGTH) return null76        return try {77            var n = BigInteger.ZERO78            for (char in input) {79                val index = BASE58_ALPHABET.indexOf(char)80                if (index < 0) return null81                n = n.multiply(BigInteger.valueOf(58)).add(BigInteger.valueOf(index.toLong()))82            }83            val bytes = n.toByteArray()84            if (bytes.isNotEmpty() && bytes[0] == 0.toByte()) bytes.copyOfRange(1, bytes.size) else bytes85        } catch (_: Exception) {86            null87        }88    }8990    private fun verifySignature(payload: ByteArray, signature: ByteArray, publicKeyHex: String): Boolean {91        return try {92            val verifier = Ed25519Signer()93            verifier.init(false, Ed25519PublicKeyParameters(hexToBytes(publicKeyHex), 0))94            verifier.update(payload, 0, payload.size)95            verifier.verifySignature(signature)96        } catch (_: Exception) {97            false98        }99    }100101    private fun hexToBytes(hex: String): ByteArray {102        val len = hex.length103        val data = ByteArray(len / 2)104        for (i in 0 until len step 2) {105            data[i / 2] = ((Character.digit(hex[i], 16) shl 4) +106                Character.digit(hex[i + 1], 16)).toByte()107        }108        return data109    }110}