Files
GPS2Audio/app/src/main/kotlin/de/waypointaudio/license/LicenseValidator.kt
T
2026-05-30 05:42:44 +00:00

181 lines
6.8 KiB
Kotlin

package de.waypointaudio.license
import android.content.Context
import android.util.Base64
import de.waypointaudio.R
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
import java.security.KeyFactory
import java.security.PublicKey
import java.security.Signature
import java.security.spec.X509EncodedKeySpec
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
/**
* v2.5.20 — Echte Offline-Lizenzprüfung.
*
* v2.5.17 hat lediglich den Public Key als Raw-Resource eingebettet und ein
* Skelett für die spätere Prüfung bereitgestellt. v2.5.20 implementiert nun:
* - Lesen einer `.gps2audio-license`-Datei (JSON).
* - Strukturelle Prüfung (`schema_version`, `payload`, `product`, Signatur-
* Algorithmus).
* - ECDSA-P256-SHA256-Verifikation gegen den eingebetteten Public Key.
* - Gültigkeitsprüfung (`valid_until` falls gesetzt).
*
* Die Methode [verifyOffline] gibt ein [Result]-Objekt mit dem Ergebnis
* zurück. Sie wirft NIEMALS — auch nicht bei ungültigem JSON oder fehlender
* Resource — sondern liefert ein passendes Fehlerergebnis.
*
* WICHTIG: Diese Prüfung beeinflusst KEINE App-Funktion. Sie wird nur für
* die Anzeige im Lizenzbereich verwendet (siehe [LicenseStatusProvider]).
* Der Private Key bleibt ausschliesslich beim Ersteller.
*/
object LicenseValidator {
private const val SCHEMA_VERSION = "gps2audio-license-v1"
private const val SIGNATURE_ALGORITHM = "ECDSA_P256_SHA256"
private const val PRODUCT_NAME = "GPS2Audio"
data class Result(
val state: LicenseStatus.State,
val payload: JSONObject? = null,
val rawJson: String? = null,
val message: String? = null,
) {
val isValid: Boolean get() = state == LicenseStatus.State.VALID
}
fun readEmbeddedPublicKeyPem(context: Context): String? = try {
context.resources.openRawResource(R.raw.gps2audio_license_public).use { input ->
BufferedReader(InputStreamReader(input, Charsets.UTF_8)).readText()
}
} catch (_: Throwable) {
null
}
/**
* Prüft den übergebenen Lizenz-Dateiinhalt offline.
*
* Reihenfolge der Checks:
* 1. JSON parsen.
* 2. `schema_version` muss "gps2audio-license-v1" sein.
* 3. `payload.product` muss "GPS2Audio" sein.
* 4. `signature.algorithm` muss "ECDSA_P256_SHA256" sein.
* 5. ECDSA-Verifikation gegen den eingebetteten Public Key.
* 6. `payload.valid_until` (falls vorhanden) darf nicht in der
* Vergangenheit liegen.
*/
fun verifyOffline(context: Context, licenseFileBytes: ByteArray): Result {
val raw = try {
String(licenseFileBytes, Charsets.UTF_8)
} catch (_: Throwable) {
return Result(LicenseStatus.State.INVALID, message = "Datei ist kein gültiges UTF-8")
}
val doc = try {
JSONObject(raw)
} catch (_: Throwable) {
return Result(LicenseStatus.State.INVALID, message = "Lizenzdatei ist kein gültiges JSON")
}
val schemaVersion = doc.optString("schema_version", "")
val payload = doc.optJSONObject("payload")
val signatureObj = doc.optJSONObject("signature")
if (schemaVersion != SCHEMA_VERSION || payload == null) {
return Result(LicenseStatus.State.INVALID, payload, raw, "Ungültiges Lizenzformat")
}
val product = payload.optString("product", "")
if (product != PRODUCT_NAME) {
return Result(LicenseStatus.State.INVALID, payload, raw, "Falsches Produkt: $product")
}
val algorithm = signatureObj?.optString("algorithm", "") ?: ""
if (algorithm != SIGNATURE_ALGORITHM) {
return Result(LicenseStatus.State.INVALID, payload, raw, "Unbekannter Signaturalgorithmus")
}
val signatureValue = signatureObj.optString("value", "")
val signatureBytes = try {
Base64.decode(signatureValue, Base64.DEFAULT)
} catch (_: Throwable) {
return Result(LicenseStatus.State.INVALID, payload, raw, "Signatur ist kein gültiges Base64")
}
val publicKey = loadEmbeddedPublicKey(context)
?: return Result(LicenseStatus.State.INVALID, payload, raw, "Eingebetteter Public Key fehlt")
val signedContent = JSONObject().apply {
put("schema_version", schemaVersion)
put("payload", payload)
}
val canonical = CanonicalJson.encode(signedContent)
val signatureValid = try {
val sig = Signature.getInstance("SHA256withECDSA")
sig.initVerify(publicKey)
sig.update(canonical.toByteArray(Charsets.UTF_8))
sig.verify(signatureBytes)
} catch (_: Throwable) {
false
}
if (!signatureValid) {
return Result(LicenseStatus.State.INVALID, payload, raw, "Signatur ungültig")
}
val validUntilRaw = payload.optString("valid_until", "")
val validUntilNonNull = if (payload.isNull("valid_until")) null else validUntilRaw.ifEmpty { null }
if (validUntilNonNull != null) {
val expiry = parseIsoDate(validUntilNonNull)
if (expiry == null) {
return Result(LicenseStatus.State.INVALID, payload, raw, "Ungültiges Ablaufdatum")
}
if (expiry.before(Date())) {
return Result(LicenseStatus.State.EXPIRED, payload, raw, "Lizenz abgelaufen")
}
}
return Result(LicenseStatus.State.VALID, payload, raw, "Lizenz ist gültig")
}
private fun loadEmbeddedPublicKey(context: Context): PublicKey? {
val pem = readEmbeddedPublicKeyPem(context) ?: return null
return try {
val base64 = pem
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replace("\\s".toRegex(), "")
val der = Base64.decode(base64, Base64.DEFAULT)
val spec = X509EncodedKeySpec(der)
KeyFactory.getInstance("EC").generatePublic(spec)
} catch (_: Throwable) {
null
}
}
private fun parseIsoDate(value: String): Date? {
val cleaned = value.trim().let {
if (it.endsWith("Z")) it.dropLast(1) + "+0000" else it.replace(Regex("([+-]\\d{2}):(\\d{2})$"), "$1$2")
}
val formats = listOf(
"yyyy-MM-dd'T'HH:mm:ssZ",
"yyyy-MM-dd'T'HH:mm:ss.SSSZ",
"yyyy-MM-dd",
)
for (pattern in formats) {
try {
val sdf = SimpleDateFormat(pattern, Locale.US)
sdf.timeZone = TimeZone.getTimeZone("UTC")
return sdf.parse(cleaned)
} catch (_: Throwable) {
// try next
}
}
return null
}
}