Release GPS2Audio v2.5.44 POI category fix
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user