Skip to content

Commit d51609f

Browse files
committed
fix apksecurity
1 parent 9bb934f commit d51609f

5 files changed

Lines changed: 132 additions & 23 deletions

File tree

‎.github/workflows/beta.yml‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,13 +103,31 @@ jobs:
103103
esac
104104
done
105105
106-
- name: Signature report
106+
# /!\ --min-sdk-version 23 est OBLIGATOIRE pour que ce rapport dise la verite. Sans lui,
107+
# apksigner prend le minSdk de l'APK (28) et ne verifie qu'en v3 : il affiche alors
108+
# "Verified using v1 scheme: false" sur un APK qui CONTIENT bien une signature v1.
109+
# Ce faux negatif a deja fait chercher une panne de cle la ou le reseau etait en cause.
110+
- name: Verify signature schemes
107111
run: |
108112
APKSIGNER=$(ls "$ANDROID_SDK_ROOT"/build-tools/*/apksigner | sort -V | tail -1)
109113
for f in dist/*.apk; do
110114
echo "-- $(basename "$f") --"
111-
"$APKSIGNER" verify --print-certs -v "$f" 2>&1 \
112-
| grep -E 'Verified using v[0-9]|certificate SHA-256 digest' || echo " (non verifiable)"
115+
"$APKSIGNER" verify --print-certs -v --min-sdk-version 23 "$f" \
116+
| grep -E 'Verified using v[0-9]|certificate SHA-256 digest'
117+
# Presence REELLE de la signature v1, independamment de ce qu'apksigner choisit de
118+
# verifier. Elle est indispensable : sur AAOS 9 le controle OTA lit l'archive via
119+
# getPackageArchiveInfo(), qui ne remonte aucun certificat sans v1 - l'application
120+
# refuse alors la mise a jour. Mieux vaut casser le build que publier ca.
121+
if ! unzip -l "$f" | grep -qE 'META-INF/.*[.](SF|RSA|DSA|EC)$'; then
122+
echo "::error::$(basename "$f") n a PAS de signature v1 (JAR) - l OTA la refuserait"
123+
exit 1
124+
fi
125+
# Empreinte du fichier publie. Le vehicule journalise la meme chose apres
126+
# telechargement ("APK telecharge : N octets, sha256=..."). Deux lignes identiques
127+
# prouvent un transfert intact ; une divergence designe le reseau, pas la signature.
128+
echo " v1 (JAR) presente : OUI"
129+
echo " taille : $(stat -c%s "$f") octets"
130+
echo " sha256 : $(sha256sum "$f" | cut -d' ' -f1)"
113131
done
114132
115133
- name: Publish pre-release

‎app/src/main/java/com/mg4/control/update/ApkDownloader.kt‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.mg4.control.update
22

3+
import com.mg4.control.debug.AppLogger
34
import kotlinx.coroutines.Dispatchers
45
import kotlinx.coroutines.withContext
56
import java.io.File
@@ -13,6 +14,8 @@ import java.net.URL
1314
*/
1415
object ApkDownloader {
1516

17+
private const val TAG = "MG4_UPDATE"
18+
1619
/** Plafond de taille : l'APK fait ~10 Mo, au-delà on refuse plutôt que remplir le disque. */
1720
private const val MAX_APK_BYTES = 100L * 1024 * 1024
1821

@@ -61,6 +64,30 @@ object ApkDownloader {
6164
}
6265

6366
conn.disconnect()
67+
68+
// ⚠️ Un transfert peut s'arreter SANS lever d'exception : connexion coupee, keep-alive
69+
// rompu, reseau de la voiture qui decroche. La boucle se termine alors normalement et
70+
// laisse un fichier court mais non vide, que `length() > 0` laissait passer.
71+
//
72+
// Cet APK tronque partait ensuite au verificateur de signature, qui n'y lisait aucun
73+
// certificat et annoncait « Signature de l'APK non conforme ». Le symptome accusait la
74+
// cle de signature alors que la cause etait le reseau : compter les octets evite
75+
// d'envoyer enqueter sur la mauvaise piste.
76+
if (totalBytes > 0 && downloaded != totalBytes) {
77+
AppLogger.w(TAG, "Telechargement incomplet : $downloaded/$totalBytes octets " +
78+
"— APK rejete (reseau interrompu, ce n'est PAS un probleme de signature)")
79+
destFile.delete()
80+
return@withContext false
81+
}
82+
if (downloaded == 0L) {
83+
AppLogger.w(TAG, "Telechargement vide — APK rejete")
84+
destFile.delete()
85+
return@withContext false
86+
}
87+
// sha256 + taille : a comparer avec les lignes publiees par la CI (etape
88+
// "Verify signature schemes" de beta.yml). Deux empreintes identiques prouvent que
89+
// l'octet a octet est intact ; une divergence designe le transfert, pas la signature.
90+
AppLogger.i(TAG, "APK telecharge : $downloaded octets, sha256=${sha256(destFile)}")
6491
destFile.exists() && destFile.length() > 0
6592

6693
} catch (_: Exception) {
@@ -75,6 +102,19 @@ object ApkDownloader {
75102
* accepter une rétrogradation https → http ou un hôte arbitraire.
76103
* Renvoie null si un saut sort de la liste d'origines autorisées.
77104
*/
105+
/** Empreinte du fichier recu, pour la confronter a celle publiee par la CI. */
106+
private fun sha256(f: File): String = try {
107+
val md = java.security.MessageDigest.getInstance("SHA-256")
108+
f.inputStream().use { input ->
109+
val buf = ByteArray(8_192)
110+
var read: Int
111+
while (input.read(buf).also { read = it } != -1) md.update(buf, 0, read)
112+
}
113+
md.digest().joinToString("") { "%02x".format(it) }
114+
} catch (e: Exception) {
115+
"(illisible: ${e.message})"
116+
}
117+
78118
private fun openConnection(urlStr: String, depth: Int = 0): HttpURLConnection? {
79119
if (!ApkUrlPolicy.isAllowedLogged(urlStr, "Redirection")) return null
80120
val conn = (URL(urlStr).openConnection() as HttpURLConnection).apply {

‎app/src/main/java/com/mg4/control/update/ApkInstaller.kt‎

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,31 @@ object ApkInstaller {
3131
return "APK manquant ou vide : ${apkFile.absolutePath}"
3232
}
3333

34-
// [RÉACTIVÉ 2026-08-05] Contrôle de signature (T-901). Les échecs précédents ont eu
35-
// lieu avec une app installée signée à la main (v1 seul) face à un APK CI (v2/v3) :
36-
// on retente avec les DEUX côtés produits par la CI, donc signés à l'identique.
37-
// L'app tourne en uid.system : on n'installe que ce qui est signé par notre clé.
38-
if (!ApkSignatureVerifier.matchesRunningApp(context, apkFile)) {
39-
apkFile.runCatching { delete() }
40-
return "Signature non conforme — APK rejeté et supprimé"
41-
}
34+
// ─────────────────────────────────────────────────────────────────────
35+
// [DESACTIVE 2026-08-18 - decision utilisateur]
36+
// Contrôle de signature (T-901) mis en commentaire à la demande explicite du
37+
// propriétaire du projet : l'OTA ne pointe que vers son propre dépôt GitHub.
38+
//
39+
// CE QUI RESTE EN PLACE : ApkUrlPolicy (origine https + hôtes autorisés) et le
40+
// contrôle de complétude du téléchargement dans ApkDownloader — c'est ce dernier
41+
// qui traitait réellement la panne du 18/08 (APK tronqué), pas ce bloc-ci.
42+
//
43+
// CE QUI N'EST PLUS COUVERT : un APK servi par un hôte autorisé mais signé par une
44+
// AUTRE clé s'installera désormais sans objection, alors que l'app tourne en
45+
// uid.system. Le repli GitLab compte parmi ces hôtes autorisés.
46+
//
47+
// POUR RÉACTIVER : décommenter le bloc ci-dessous (rien d'autre à faire —
48+
// ApkSignatureVerifier et ses tests sont restés intacts).
49+
// ─────────────────────────────────────────────────────────────────────
50+
// if (!ApkSignatureVerifier.matchesRunningApp(context, apkFile)) {
51+
// apkFile.runCatching { delete() }
52+
// return "Signature non conforme — APK rejeté et supprimé"
53+
// }
54+
//
55+
// La MESURE, elle, est conservée : elle ne bloque rien mais elle dit dans le log si la
56+
// signature aurait été acceptée. Sans elle, un futur échec d'installation redeviendrait
57+
// indiscernable d'un problème de clé.
58+
ApkSignatureVerifier.logVerdict(context, apkFile)
4259

4360
// ── Stratégie 1 : pm install depuis le stockage externe ───────────────
4461
val extDir = context.getExternalFilesDir(null)

‎app/src/main/java/com/mg4/control/update/ApkSecurity.kt‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,34 @@ object ApkSignatureVerifier {
9292
return ok
9393
}
9494

95+
/**
96+
* Journalise le verdict de signature SANS rien bloquer.
97+
*
98+
* Le contrôle bloquant a été désactivé à la demande du propriétaire (voir [ApkInstaller]),
99+
* mais garder la MESURE permet de trancher, log en main, entre « signature réellement
100+
* différente » et « archive illisible parce que le téléchargement a été coupé ». Les deux
101+
* produisaient le même message auparavant, et c'est précisément ce qui a fait chercher une
102+
* panne de clé là où le réseau était en cause.
103+
*
104+
* Ne renvoie volontairement RIEN : le résultat ne doit pas pouvoir être réutilisé comme
105+
* condition, sans quoi le contrôle redeviendrait bloquant par inadvertance.
106+
*/
107+
fun logVerdict(context: Context, apk: File) {
108+
val taille = runCatching { apk.length() }.getOrDefault(-1L)
109+
val archive = fingerprintsOfArchive(context, apk)
110+
val installed = fingerprintsOfInstalled(context)
111+
if (certsMatch(archive, installed)) {
112+
AppLogger.i(TAG, "[AUDIT] Signature CONFORME (non bloquant) — ${apk.name}, $taille octets")
113+
} else {
114+
AppLogger.w(TAG, "[AUDIT] Signature NON CONFORME (non bloquant, installation poursuivie) — " +
115+
"${apk.name}, $taille octets, archive=${archive.size} cert(s), installee=${installed.size} cert(s)")
116+
AppLogger.w(TAG, "[AUDIT] archive : ${describe(archive)}")
117+
AppLogger.w(TAG, "[AUDIT] installee : ${describe(installed)}")
118+
if (archive.isEmpty()) AppLogger.w(TAG, "[AUDIT] archive=0 cert : archive illisible " +
119+
"(telechargement tronque, ou v1 absente) — ce n'est PAS la preuve d'une cle differente")
120+
}
121+
}
122+
95123
/** Empreintes tronquées, suffisantes pour identifier une clé dans un log. */
96124
private fun describe(certs: Set<String>): String =
97125
if (certs.isEmpty()) "(aucune — archive illisible, non signée, ou signée v2 sans v1)"

‎app/src/main/java/com/mg4/control/update/UpdateDialogManager.kt‎

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -224,18 +224,24 @@ object UpdateDialogManager {
224224
when (status) {
225225
DownloadManager.STATUS_SUCCESSFUL -> {
226226
progressBar.progress = 100
227-
// [RÉACTIVÉ 2026-08-05] Contrôle de signature (T-901) — voir ApkInstaller.
228-
// L'APK est dans un dossier public : on vérifie qu'il est signé
229-
// par NOTRE clé avant d'inviter l'utilisateur à l'installer.
230-
val downloaded = File(
231-
Environment.getExternalStoragePublicDirectory(
232-
Environment.DIRECTORY_DOWNLOADS), fileName)
233-
if (!ApkSignatureVerifier.matchesRunningApp(activity, downloaded)) {
234-
downloaded.runCatching { delete() }
235-
tvStatus.setText(R.string.update_error_signature)
236-
btnCancel.setText(R.string.update_close)
237-
break
238-
}
227+
// [DESACTIVE 2026-08-18 - decision utilisateur]
228+
// Contrôle de signature (T-901) mis en commentaire — voir ApkInstaller
229+
// pour le raisonnement complet et la marche à suivre pour réactiver.
230+
// La chaîne string update_error_signature est conservée exprès.
231+
// val downloaded = File(
232+
// Environment.getExternalStoragePublicDirectory(
233+
// Environment.DIRECTORY_DOWNLOADS), fileName)
234+
// if (!ApkSignatureVerifier.matchesRunningApp(activity, downloaded)) {
235+
// downloaded.runCatching { delete() }
236+
// tvStatus.setText(R.string.update_error_signature)
237+
// btnCancel.setText(R.string.update_close)
238+
// break
239+
// }
240+
// Mesure conservée, non bloquante — voir ApkSecurity.logVerdict.
241+
ApkSignatureVerifier.logVerdict(
242+
activity,
243+
File(Environment.getExternalStoragePublicDirectory(
244+
Environment.DIRECTORY_DOWNLOADS), fileName))
239245
// Nettoie les anciens APK dans Téléchargements (garde les 5 plus récents)
240246
ApkCleanup.cleanIfNeeded()
241247
// Ouvre le dossier Téléchargements dans le gestionnaire AAOS

0 commit comments

Comments
 (0)