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:
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."
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user