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 } }