fix: corrections de l'audit de sécurité et de robustesse

- VPN retiré : le contrôle reste activé (alerte parent, tunnel remonté après 30 s, autorisation redemandée à l'ouverture)
- Horloge de référence : changer date/heure/fuseau ne contourne plus les plages horaires ni le temps d'écran (signalé au parent)
- Mot de passe : PBKDF2 + attente croissante après 3 échecs ; conversion automatique des anciens hachages
- Écrans protégés : aucun affichage avant chargement des réglages ; effacement du journal protégé
- Signature release via keystore.properties (même clé que les versions publiées)
- Sauvegarde Google : réglages, appairage et journal exclus
- Alerte admin fiable (goAsync), relance refusée signalée, liste d'applis renvoyée à chaque installation, code d'appairage consommé
- Journal thread-safe et sans recopie à chaque ligne ; correctifs lint
- Tests unitaires (mot de passe, règles, versions, config Firebase) ; README aligné sur le comportement réel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9CBCVP5oDu2sfoYPisNCQ
This commit is contained in:
tuxgyverandClaude Opus 5.5 committed 2026-10-25 10:49:43 +01:00
1 parent f33776bbb5
commit e269e9932a
37 files changed
+809 -147

No files matched your search

@@ -2,10 +2,20 @@ package com.jttools.datacontrol.data
import java.security.MessageDigest
import java.security.SecureRandom
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec
/** Hachage salé (SHA-256) du mot de passe de verrouillage — jamais stocké en clair. */
/**
* Hachage du mot de passe de verrouillage (PBKDF2-HMAC-SHA256, salé) — jamais stocké en clair.
*
* Format stocké : `pbkdf2:<itérations>:<hex>`. Les anciens hachages (SHA-256 simple, v0.1.8 et avant)
* restent vérifiables et sont convertis au prochain déverrouillage réussi.
*/
object PasswordHasher {
private const val SALT_BYTES = 16
private const val ITERATIONS = 100_000
private const val KEY_BITS = 256
private const val PREFIX = "pbkdf2:"
fun newSalt(): String {
val bytes = ByteArray(SALT_BYTES)
@@ -13,11 +23,40 @@ object PasswordHasher {
return bytes.toHex()
}
fun hash(password: String, saltHex: String): String {
fun hash(password: String, saltHex: String): String =
"$PREFIX$ITERATIONS:${pbkdf2(password, saltHex, ITERATIONS)}"
fun verify(password: String, saltHex: String, stored: String): Boolean {
val computed = if (stored.startsWith(PREFIX)) {
val iterations = stored.removePrefix(PREFIX).substringBefore(':').toIntOrNull() ?: return false
"$PREFIX$iterations:${pbkdf2(password, saltHex, iterations)}"
} else {
legacySha256(password, saltHex)
}
// Comparaison à temps constant.
return MessageDigest.isEqual(computed.toByteArray(Charsets.UTF_8), stored.toByteArray(Charsets.UTF_8))
}
/** Vrai pour un hachage ancien format, à recalculer avec [hash]. */
fun needsUpgrade(stored: String): Boolean = !stored.startsWith("$PREFIX$ITERATIONS:")
private fun pbkdf2(password: String, saltHex: String, iterations: Int): String {
val spec = PBEKeySpec(password.toCharArray(), saltHex.hexToBytes(), iterations, KEY_BITS)
try {
return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).encoded.toHex()
} finally {
spec.clearPassword()
}
}
private fun legacySha256(password: String, saltHex: String): String {
val digest = MessageDigest.getInstance("SHA-256")
digest.update(saltHex.toByteArray(Charsets.UTF_8))
return digest.digest(password.toByteArray(Charsets.UTF_8)).toHex()
}
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
private fun String.hexToBytes(): ByteArray =
ByteArray(length / 2) { i -> substring(2 * i, 2 * i + 2).toInt(16).toByte() }
}
@@ -1,6 +1,10 @@
package com.jttools.datacontrol.data
import android.content.Context
import android.os.SystemClock
import com.jttools.datacontrol.log.AppLog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.doublePreferencesKey
import androidx.datastore.preferences.core.edit
@@ -56,6 +60,16 @@ class SettingsRepository @Inject constructor(
val TRAFFIC_WIFI_ACC = longPreferencesKey("traffic_wifi_acc")
val TRAFFIC_LAST_MOBILE = longPreferencesKey("traffic_last_mobile")
val TRAFFIC_LAST_OTHER = longPreferencesKey("traffic_last_other")
val PASSWORD_FAILS = intPreferencesKey("password_fails")
val PASSWORD_LOCK_UNTIL = longPreferencesKey("password_lock_until")
val CLOCK_SKEW_MS = longPreferencesKey("clock_skew_ms")
val CLOCK_ZONE = stringPreferencesKey("clock_zone")
}
private companion object {
const val FREE_ATTEMPTS = 3
const val LOCK_BASE_MS = 30_000L
const val LOCK_MAX_MS = 15 * 60_000L
}
val settingsFlow: Flow<AppSettings> = context.dataStore.data.map { prefs ->
@@ -168,14 +182,29 @@ class SettingsRepository @Inject constructor(
}
} else {
val salt = PasswordHasher.newSalt()
val hash = PasswordHasher.hash(password, salt)
val hash = withContext(Dispatchers.Default) { PasswordHasher.hash(password, salt) }
context.dataStore.edit { prefs ->
prefs[Keys.PASSWORD_HASH] = hash
prefs[Keys.PASSWORD_SALT] = salt
prefs.remove(Keys.PASSWORD_FAILS)
prefs.remove(Keys.PASSWORD_LOCK_UNTIL)
}
}
}
/** Horloge de référence de [com.jttools.datacontrol.guard.GuardEngine] : décalage (ms) et fuseau retenus. */
suspend fun trustedClock(): Pair<Long, String?> {
val prefs = context.dataStore.data.first()
return (prefs[Keys.CLOCK_SKEW_MS] ?: 0L) to prefs[Keys.CLOCK_ZONE]
}
suspend fun setTrustedClock(skewMs: Long, zoneId: String) {
context.dataStore.edit {
if (skewMs == 0L) it.remove(Keys.CLOCK_SKEW_MS) else it[Keys.CLOCK_SKEW_MS] = skewMs
it[Keys.CLOCK_ZONE] = zoneId
}
}
/**
* Réinitialisation unique (v0.1.2) du mot de passe de verrouillage : le supprime une seule fois,
* puis mémorise qu'elle a eu lieu pour ne pas effacer un mot de passe défini ensuite.
@@ -190,12 +219,63 @@ class SettingsRepository @Inject constructor(
}
}
/** Vrai si aucun mot de passe n'est défini, ou si [password] correspond au mot de passe stocké. */
suspend fun verifyPassword(password: String): Boolean {
/** Fin du blocage en temps monotone : avancer l'horloge du téléphone ne raccourcit pas l'attente. */
@Volatile private var lockUntilElapsed = 0L
/**
* Vérifie [password]. Renvoie null s'il est correct (ou si aucun mot de passe n'est défini),
* sinon le message d'erreur à afficher. Après [FREE_ATTEMPTS] échecs, chaque nouvel essai
* impose une attente qui double (30 s, 1 min, 2 min… jusqu'à 15 min).
*/
suspend fun checkPassword(password: String): String? {
val prefs = context.dataStore.data.first()
val hash = prefs[Keys.PASSWORD_HASH] ?: return true
val salt = prefs[Keys.PASSWORD_SALT] ?: return true
return PasswordHasher.hash(password, salt) == hash
val hash = prefs[Keys.PASSWORD_HASH] ?: return null
val salt = prefs[Keys.PASSWORD_SALT] ?: return null
val remaining = maxOf(
(prefs[Keys.PASSWORD_LOCK_UNTIL] ?: 0L) - System.currentTimeMillis(),
lockUntilElapsed - SystemClock.elapsedRealtime()
)
if (remaining > 0) return lockMessage(remaining)
val ok = withContext(Dispatchers.Default) { PasswordHasher.verify(password, salt, hash) }
if (ok) {
val upgraded = if (PasswordHasher.needsUpgrade(hash)) {
withContext(Dispatchers.Default) { PasswordHasher.newSalt().let { it to PasswordHasher.hash(password, it) } }
} else null
lockUntilElapsed = 0L
context.dataStore.edit {
it.remove(Keys.PASSWORD_FAILS)
it.remove(Keys.PASSWORD_LOCK_UNTIL)
upgraded?.let { (newSalt, newHash) ->
it[Keys.PASSWORD_SALT] = newSalt
it[Keys.PASSWORD_HASH] = newHash
}
}
return null
}
var lockMs = 0L
context.dataStore.edit {
val fails = (it[Keys.PASSWORD_FAILS] ?: 0) + 1
it[Keys.PASSWORD_FAILS] = fails
if (fails >= FREE_ATTEMPTS) {
lockMs = (LOCK_BASE_MS shl (fails - FREE_ATTEMPTS).coerceAtMost(10)).coerceAtMost(LOCK_MAX_MS)
it[Keys.PASSWORD_LOCK_UNTIL] = System.currentTimeMillis() + lockMs
}
}
if (lockMs > 0) {
lockUntilElapsed = SystemClock.elapsedRealtime() + lockMs
AppLog.w("Mot de passe", "Mot de passe erroné, saisie bloquée ${lockMs / 1000} s")
return "Mot de passe incorrect. " + lockMessage(lockMs)
}
AppLog.w("Mot de passe", "Mot de passe erroné")
return "Mot de passe incorrect."
}
private fun lockMessage(ms: Long): String {
val s = (ms + 999) / 1000
return if (s < 60) "Trop d'essais : réessayez dans $s s." else "Trop d'essais : réessayez dans ${(s + 59) / 60} min."
}
/**