package com.peachpasswords.license import java.math.BigInteger import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters import org.bouncycastle.crypto.signers.Ed25519Signer /** * Pure licence decoding and verification. * * Split out of `LicenseRepository` so the validation boundary — the one write * path whose previous defect was a missing validation call — is testable without * Android's EncryptedSharedPreferences (PB-26-147). * * [publicKeyHex] defaults to the compiled-in key, which is what every production * caller uses. It is a parameter so a test can sign a blob with its own keypair * and prove the verifier rejects a tampered blob rather than rejecting * everything; nothing else may vary it. */ internal object LicenseCodec { private const val PUBLIC_KEY_HEX = "c7dc52d2c484696cf8566dbe6cfe69025d114cb47f6cf53c25088ecfd539bdfa" private const val BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" // A 76-byte payload encodes to at most 104 base58 characters. private const val MAX_BASE58_LENGTH = 104 const val PAYLOAD_SIZE = 12 const val SIGNATURE_SIZE = 64 const val TOTAL_SIZE = 76 const val PRODUCT_TAG = 0x01.toByte() const val PLAN_TAG = 0x01.toByte() const val VERSION_TAG = 0x01.toByte() /** True when [licenseKey] decodes to a correctly tagged, validly signed payload. */ fun validate(licenseKey: String, publicKeyHex: String = PUBLIC_KEY_HEX): Boolean { return try { val decoded = decodeLicenseKey(licenseKey) ?: return false if (decoded.size != TOTAL_SIZE) return false val payload = decoded.copyOfRange(0, PAYLOAD_SIZE) val signature = decoded.copyOfRange(PAYLOAD_SIZE, TOTAL_SIZE) // Tag checks first: a blob for another product/plan/version must not // reach the signature work at all. if (payload[0] != PRODUCT_TAG) return false if (payload[1] != PLAN_TAG) return false if (payload[2] != VERSION_TAG) return false verifySignature(payload, signature, publicKeyHex) } catch (_: Exception) { false } } /** Days-since-epoch issue date, or null when the blob is not a valid licence shape. */ fun issuedAtMillis(licenseKey: String): Long? { val decoded = decodeLicenseKey(licenseKey) ?: return null if (decoded.size != TOTAL_SIZE) return null val payload = decoded.copyOfRange(0, PAYLOAD_SIZE) if (payload[0] != PRODUCT_TAG || payload[1] != PLAN_TAG || payload[2] != VERSION_TAG) return null val days = (payload[3].toInt() and 0xff) or ((payload[4].toInt() and 0xff) shl 8) or ((payload[5].toInt() and 0xff) shl 16) or ((payload[6].toInt() and 0xff) shl 24) return days.toLong() * 86_400_000L } private fun decodeLicenseKey(key: String): ByteArray? { if (!key.startsWith("PPK-")) return null val encoded = key.removePrefix("PPK-").replace("-", "") return base58Decode(encoded) } private fun base58Decode(input: String): ByteArray? { if (input.length > MAX_BASE58_LENGTH) return null return try { var n = BigInteger.ZERO for (char in input) { val index = BASE58_ALPHABET.indexOf(char) if (index < 0) return null n = n.multiply(BigInteger.valueOf(58)).add(BigInteger.valueOf(index.toLong())) } val bytes = n.toByteArray() if (bytes.isNotEmpty() && bytes[0] == 0.toByte()) bytes.copyOfRange(1, bytes.size) else bytes } catch (_: Exception) { null } } private fun verifySignature(payload: ByteArray, signature: ByteArray, publicKeyHex: String): Boolean { return try { val verifier = Ed25519Signer() verifier.init(false, Ed25519PublicKeyParameters(hexToBytes(publicKeyHex), 0)) verifier.update(payload, 0, payload.size) verifier.verifySignature(signature) } catch (_: Exception) { false } } private fun hexToBytes(hex: String): ByteArray { val len = hex.length val data = ByteArray(len / 2) for (i in 0 until len step 2) { data[i / 2] = ((Character.digit(hex[i], 16) shl 4) + Character.digit(hex[i + 1], 16)).toByte() } return data } }