181 lines
6.8 KiB
Kotlin
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
|
|
}
|
|
}
|