Skip to content

Commit c5ed5b8

Browse files
committed
reactivating the signature verification
1 parent b0c5675 commit c5ed5b8

4 files changed

Lines changed: 87 additions & 80 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,16 @@ jobs:
2828
uses: gradle/actions/setup-gradle@v4
2929

3030
- name: Decode platform keystore
31-
if: ${{ env.MG4_KEYSTORE_BASE64 != '' }}
3231
run: |
32+
# An absent secret used to SKIP this step silently. Gradle then created no signing
33+
# config and the workflow published an UNSIGNED APK -- which no device can install
34+
# now that the T-901 signature check is enforced again. Fail loudly instead, which
35+
# is what beta.yml already does.
36+
if [ -z "$MG4_KEYSTORE_BASE64" ]; then
37+
echo "::error::MG4_KEYSTORE_BASE64 is missing. The APK would be unsigned and every"
38+
echo "::error::client would reject it at install time (signature check T-901)."
39+
exit 1
40+
fi
3341
# Tolerate newlines/spaces in the secret; printf avoids a stray trailing newline.
3442
printf '%s' "$MG4_KEYSTORE_BASE64" | tr -d '[:space:]' | base64 -d > "$RUNNER_TEMP/platform.keystore"
3543
# Validation: a PKCS12/JKS keystore starts with 0x30 (DER SEQUENCE). Otherwise the secret is corrupt.
@@ -54,6 +62,29 @@ jobs:
5462
find app/build/outputs/apk -name "MG4Control-*-*.apk" -exec cp {} dist/ \;
5563
ls -la dist/
5664
65+
# /!\ --min-sdk-version 23 is REQUIRED for this report to tell the truth. Without it,
66+
# apksigner uses the APK's own minSdk (28), verifies with v3 only, and prints
67+
# "Verified using v1 scheme: false" on an APK that DOES carry a v1 signature.
68+
- name: Verify signature schemes
69+
run: |
70+
APKSIGNER=$(ls "$ANDROID_SDK_ROOT"/build-tools/*/apksigner | sort -V | tail -1)
71+
for f in dist/*.apk; do
72+
echo "-- $(basename "$f") --"
73+
"$APKSIGNER" verify --print-certs -v --min-sdk-version 23 "$f" \
74+
| grep -E 'Verified using v[0-9]|certificate SHA-256 digest'
75+
# Actual presence of the v1 signature, regardless of what apksigner chose to verify
76+
# with. On AAOS 9 the OTA check reads the archive through getPackageArchiveInfo(),
77+
# which reports no certificate without v1 -- the app then refuses the update.
78+
# Better to break the build than to publish that.
79+
if ! unzip -l "$f" | grep -qE 'META-INF/.*[.](SF|RSA|DSA|EC)$'; then
80+
echo "::error::$(basename "$f") has NO v1 (JAR) signature - clients would reject it"
81+
exit 1
82+
fi
83+
echo " v1 (JAR) present : YES"
84+
echo " size : $(stat -c%s "$f") bytes"
85+
echo " sha256 : $(sha256sum "$f" | cut -d' ' -f1)"
86+
done
87+
5788
- name: Upload APKs as workflow artifacts
5889
uses: actions/upload-artifact@v4
5990
with:

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

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

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.
34+
// [RÉACTIVÉ 2026-08-18] Contrôle de signature (T-901), après correction de sa cause
35+
// racine : `getPackageArchiveInfo()` ne renseigne pas `signingInfo` pour une ARCHIVE sur
36+
// API 28, donc la lecture rendait 0 certificat sur un APK parfaitement signé et le
37+
// contrôle échouait systématiquement (voir ApkSecurity.fingerprintsOfArchive).
38+
// Validé sur véhicule : beta.8 -> beta.9, "Signature CONFORME".
3839
//
39-
// CE QUI RESTE EN PLACE : ApkUrlPolicy (origine https + hôtes autorisés).
40-
// NB : ApkDownloader n'a aucun appelant — l'OTA télécharge via le DownloadManager
41-
// d'Android (voir UpdateDialogManager) ; son contrôle de complétude ne protège donc
42-
// pas ce flux. La panne du 18/08 n'était pas un APK tronqué mais une lecture de
43-
// signature d'archive erronée, corrigée dans ApkSecurity.fingerprintsOfArchive.
44-
//
45-
// CE QUI N'EST PLUS COUVERT : un APK servi par un hôte autorisé mais signé par une
46-
// AUTRE clé s'installera désormais sans objection, alors que l'app tourne en
47-
// uid.system. Le repli GitLab compte parmi ces hôtes autorisés.
48-
//
49-
// POUR RÉACTIVER : décommenter le bloc ci-dessous (rien d'autre à faire —
50-
// ApkSignatureVerifier et ses tests sont restés intacts).
51-
// ─────────────────────────────────────────────────────────────────────
52-
// if (!ApkSignatureVerifier.matchesRunningApp(context, apkFile)) {
53-
// apkFile.runCatching { delete() }
54-
// return "Signature non conforme — APK rejeté et supprimé"
55-
// }
56-
//
57-
// La MESURE, elle, est conservée : elle ne bloque rien mais elle dit dans le log si la
58-
// signature aurait été acceptée. Sans elle, un futur échec d'installation redeviendrait
59-
// indiscernable d'un problème de clé.
60-
ApkSignatureVerifier.logVerdict(context, apkFile)
40+
// L'app tourne en uid.system : on n'installe que ce qui est signé par notre clé.
41+
// Le verdict ne dépend QUE des certificats, jamais de la taille de l'APK.
42+
if (!ApkSignatureVerifier.matchesRunningApp(context, apkFile)) {
43+
apkFile.runCatching { delete() }
44+
return "Signature non conforme — APK rejeté et supprimé"
45+
}
6146

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

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

Lines changed: 32 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -72,52 +72,49 @@ object ApkSignatureVerifier {
7272
archive.isNotEmpty() && installed.isNotEmpty() && archive == installed
7373

7474
/**
75-
* Vrai si [apk] est signé exactement par la même clé que l'app installée.
76-
* Toute erreur (archive corrompue, API indisponible) renvoie false.
75+
* Vrai si [apk] est signé exactement par la même clé que l'app installée — et journalise le
76+
* verdict dans les deux cas. Toute erreur (archive illisible, API indisponible) renvoie false.
77+
*
78+
* Une seule fonction fait la mesure ET la décision : les avoir séparées ferait analyser
79+
* l'archive et calculer son sha256 deux fois par installation, pour un résultat identique.
80+
*
81+
* ⚠️ Le verdict ne repose QUE sur les empreintes de certificats. Ni la taille ni le sha256
82+
* n'y participent — ils ne servent qu'au log, pour se confronter aux lignes publiées par la
83+
* CI. Un APK légitime change forcément de poids d'une version à l'autre.
7784
*/
7885
fun matchesRunningApp(context: Context, apk: File): Boolean {
86+
val taille = runCatching { apk.length() }.getOrDefault(-1L)
87+
val empreinte = sha256(apk)
7988
val archive = fingerprintsOfArchive(context, apk)
8089
val installed = fingerprintsOfInstalled(context)
8190
val ok = certsMatch(archive, installed)
82-
if (!ok) {
83-
AppLogger.w(TAG, "Signature de l'APK non conforme — installation refusée " +
84-
"(archive=${archive.size} cert(s), installée=${installed.size} cert(s))")
85-
// Les compteurs seuls ne permettent pas de trancher entre les trois causes réelles :
86-
// archive illisible (0 cert — typiquement une signature v2 sans v1, que
87-
// getPackageArchiveInfo ne remonte pas sur AAOS 9), clé différente, ou lecture de
88-
// notre propre signature en échec. Les empreintes le disent en une ligne.
91+
if (ok) {
92+
AppLogger.i(TAG, "Signature CONFORME — ${apk.name}, $taille octets, sha256=$empreinte")
93+
} else {
94+
AppLogger.w(TAG, "Signature NON CONFORME — installation refusée : ${apk.name}, " +
95+
"$taille octets, sha256=$empreinte, " +
96+
"archive=${archive.size} cert(s), installee=${installed.size} cert(s)")
97+
// Les compteurs seuls ne distinguent pas les trois causes réelles : archive illisible,
98+
// clé réellement différente, ou lecture de notre propre signature en échec.
8999
AppLogger.w(TAG, " archive : ${describe(archive)}")
90-
AppLogger.w(TAG, " installée : ${describe(installed)}")
100+
AppLogger.w(TAG, " installee : ${describe(installed)}")
101+
if (archive.isEmpty()) AppLogger.w(TAG, " archive=0 cert : archive illisible " +
102+
"(APK non signé, ou v1 absente) — ce n'est PAS la preuve d'une clé différente")
91103
}
92104
return ok
93105
}
94106

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")
107+
/** Empreinte du fichier reçu, à confronter à celle publiée par la CI. */
108+
private fun sha256(f: File): String = try {
109+
val md = MessageDigest.getInstance("SHA-256")
110+
f.inputStream().use { input ->
111+
val buf = ByteArray(8_192)
112+
var read: Int
113+
while (input.read(buf).also { read = it } != -1) md.update(buf, 0, read)
120114
}
115+
md.digest().joinToString("") { "%02x".format(it) }
116+
} catch (e: Exception) {
117+
"(illisible: ${e.message})"
121118
}
122119

123120
/** Empreintes tronquées, suffisantes pour identifier une clé dans un log. */

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

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -224,24 +224,18 @@ object UpdateDialogManager {
224224
when (status) {
225225
DownloadManager.STATUS_SUCCESSFUL -> {
226226
progressBar.progress = 100
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))
227+
// [RÉACTIVÉ 2026-08-18] Contrôle de signature (T-901) — voir ApkInstaller.
228+
// L'APK est dans un dossier public : on vérifie qu'il est signé par NOTRE
229+
// 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+
}
245239
// Nettoie les anciens APK dans Téléchargements (garde les 5 plus récents)
246240
ApkCleanup.cleanIfNeeded()
247241
// Ouvre le dossier Téléchargements dans le gestionnaire AAOS

0 commit comments

Comments
 (0)