Seguretat Android
Tipus: Document d'Arquitectura
Versió: 1.0
Actualitzat: 2024-12
Estat: Normatiu
Resum
Aquest document defineix l'estratègia de seguretat específica per Android a LDP. Cobreix:
- Versió mínima obligatòria
- In-App Updates
- Emmagatzematge segur offline
- Gestió de tokens
- Integritat de dispositiu (Play Integrity)
Stack Android
L'app Android de LDP utilitza Kotlin + Jetpack Compose.
1. Versió Mínima d'Aplicació
1.1. Motivació
Forçar actualitzacions quan:
- Es descobreix una vulnerabilitat crítica
- Canvis d'API incompatibles
- Expiració de certificats o tokens legacy
1.2. Implementació Backend
Headers de Versió
L'app Android ha d'enviar:
X-App-Version: 1.2.3
X-Platform: android
X-Build-Number: 45
Configuració Servidor
# application.yml
app:
mobile:
android:
min-version: "1.0.0"
min-build-number: 10
force-update: false # true = bloquejar, false = avisar
Filter de Verificació
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
public class AppVersionFilter extends OncePerRequestFilter {
@Value("${app.mobile.android.min-version}")
private String minVersion;
@Value("${app.mobile.android.min-build-number}")
private int minBuildNumber;
@Value("${app.mobile.android.force-update}")
private boolean forceUpdate;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String platform = request.getHeader("X-Platform");
if ("android".equalsIgnoreCase(platform)) {
String version = request.getHeader("X-App-Version");
String buildStr = request.getHeader("X-Build-Number");
if (isOutdated(version, buildStr)) {
if (forceUpdate) {
response.setStatus(HttpStatus.UPGRADE_REQUIRED.value());
response.setContentType("application/json");
response.getWriter().write("""
{
"code": "APP_UPDATE_REQUIRED",
"message": "Actualitza l'app per continuar",
"minVersion": "%s",
"updateUrl": "https://play.google.com/store/apps/details?id=com.linedance"
}
""".formatted(minVersion));
return;
} else {
response.setHeader("X-Update-Available", "true");
response.setHeader("X-Min-Version", minVersion);
}
}
}
chain.doFilter(request, response);
}
private boolean isOutdated(String version, String buildStr) {
if (version == null) return false; // No forçar si no envia header
try {
int build = buildStr != null ? Integer.parseInt(buildStr) : 0;
return build < minBuildNumber || compareVersions(version, minVersion) < 0;
} catch (Exception e) {
return false;
}
}
private int compareVersions(String v1, String v2) {
String[] parts1 = v1.split("\\.");
String[] parts2 = v2.split("\\.");
for (int i = 0; i < Math.max(parts1.length, parts2.length); i++) {
int p1 = i < parts1.length ? Integer.parseInt(parts1[i]) : 0;
int p2 = i < parts2.length ? Integer.parseInt(parts2[i]) : 0;
if (p1 != p2) return Integer.compare(p1, p2);
}
return 0;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
// Permetre sempre auth endpoints per mostrar missatge d'update
return request.getRequestURI().startsWith("/api/auth/");
}
}
1.3. Implementació Android
// Interceptor OkHttp
class AppVersionInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder()
.header("X-App-Version", BuildConfig.VERSION_NAME)
.header("X-Build-Number", BuildConfig.VERSION_CODE.toString())
.header("X-Platform", "android")
.build()
val response = chain.proceed(request)
// Verificar si cal actualitzar
if (response.code == 426) { // Upgrade Required
// Llançar event per mostrar diàleg d'actualització
UpdateManager.notifyUpdateRequired()
} else if (response.header("X-Update-Available") == "true") {
UpdateManager.notifyUpdateAvailable()
}
return response
}
}
2. In-App Updates
2.1. Opcions d'Actualització
| Tipus | Ús | Comportament |
|---|---|---|
| Flexible | Updates normals | L'usuari pot continuar usant l'app |
| Immediate | Updates crítics | Bloqueja l'app fins actualitzar |
2.2. Implementació amb Play Core
class UpdateManager(
private val activity: Activity
) {
private val appUpdateManager = AppUpdateManagerFactory.create(activity)
fun checkForUpdates(forceImmediate: Boolean = false) {
val appUpdateInfoTask = appUpdateManager.appUpdateInfo
appUpdateInfoTask.addOnSuccessListener { appUpdateInfo ->
when {
appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE -> {
val updateType = if (forceImmediate || isHighPriority(appUpdateInfo)) {
AppUpdateType.IMMEDIATE
} else {
AppUpdateType.FLEXIBLE
}
if (appUpdateInfo.isUpdateTypeAllowed(updateType)) {
appUpdateManager.startUpdateFlowForResult(
appUpdateInfo,
updateType,
activity,
UPDATE_REQUEST_CODE
)
}
}
appUpdateInfo.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS -> {
// Reprendre update interromput
appUpdateManager.startUpdateFlowForResult(
appUpdateInfo,
AppUpdateType.IMMEDIATE,
activity,
UPDATE_REQUEST_CODE
)
}
}
}
}
private fun isHighPriority(info: AppUpdateInfo): Boolean {
// Priority 4-5 = critical
return info.updatePriority() >= 4
}
companion object {
const val UPDATE_REQUEST_CODE = 1001
}
}
3. Emmagatzematge Segur Offline
3.1. Què Guardar Offline
| Dades | Offline | Xifrat | Justificació |
|---|---|---|---|
| Catàleg de Dances | ✅ | ❌ | Públic, millora UX |
| Catàleg de Songs | ✅ | ❌ | Públic |
| Preferències usuari | ✅ | ✅ | Pot contenir info personal |
| Tokens (JWT) | ✅ | ✅ | CRÍTIC |
| Dades de perfil | ⚠️ Limitat | ✅ | Només bàsic |
| Historial de visualitzacions | ✅ | ✅ | Privat |
3.2. ADR: Opció d'Emmagatzematge Local
Decisió Pendent
L'elecció entre Room+SQLCipher vs DataStore+Crypto es documenta aquí com a ADR.
Opció A: Room + SQLCipher
Pros:
- Base de dades relacional completa
- Queries SQL complexes
- Bon suport per sync offline
- Madur i ben documentat
Contres:
- Més complex de configurar
- Major footprint (binaris nadius)
- Overhead per dades simples
// Configuració SQLCipher amb Room
val passphrase = SQLCipherUtils.getPassphrase(context)
val factory = SupportFactory(passphrase)
val database = Room.databaseBuilder(
context,
AppDatabase::class.java,
"linedance.db"
)
.openHelperFactory(factory)
.build()
Opció B: DataStore + EncryptedFile
Pros:
- Més lleuger
- API moderna (Kotlin Coroutines + Flow)
- Bon per preferències i dades simples
Contres:
- No és BD relacional
- Queries limitades
- Menys adequat per grans volums
// DataStore xifrat
val Context.dataStore by dataStore(
fileName = "settings.pb",
serializer = SettingsSerializer(
EncryptedFile.Builder(
context,
File(context.filesDir, "settings.pb"),
masterKey,
EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
).build()
)
)
Opció C: Híbrida
- Room (sense xifrat) per catàleg públic (dances, songs)
- EncryptedSharedPreferences per tokens i preferències
- EncryptedFile/DataStore per dades privades
Recomanació provisional: Opció C (híbrida)
3.3. Tokens a EncryptedSharedPreferences
object SecureTokenStorage {
private const val PREFS_NAME = "secure_tokens"
private const val KEY_ACCESS_TOKEN = "access_token"
private const val KEY_REFRESH_TOKEN = "refresh_token"
private const val KEY_TOKEN_EXPIRY = "token_expiry"
private fun getEncryptedPrefs(context: Context): SharedPreferences {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
return EncryptedSharedPreferences.create(
context,
PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
fun saveTokens(context: Context, accessToken: String, refreshToken: String, expiresAt: Long) {
getEncryptedPrefs(context).edit {
putString(KEY_ACCESS_TOKEN, accessToken)
putString(KEY_REFRESH_TOKEN, refreshToken)
putLong(KEY_TOKEN_EXPIRY, expiresAt)
}
}
fun getAccessToken(context: Context): String? {
return getEncryptedPrefs(context).getString(KEY_ACCESS_TOKEN, null)
}
fun clearTokens(context: Context) {
getEncryptedPrefs(context).edit { clear() }
}
}
3.4. Xifrat de Fitxers
Per dades més grans (JSON cache, etc.):
object SecureFileStorage {
fun writeEncryptedFile(context: Context, filename: String, data: ByteArray) {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val encryptedFile = EncryptedFile.Builder(
context,
File(context.filesDir, filename),
masterKey,
EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
).build()
encryptedFile.openFileOutput().use { output ->
output.write(data)
}
}
fun readEncryptedFile(context: Context, filename: String): ByteArray? {
val file = File(context.filesDir, filename)
if (!file.exists()) return null
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val encryptedFile = EncryptedFile.Builder(
context,
file,
masterKey,
EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
).build()
return encryptedFile.openFileInput().use { input ->
input.readBytes()
}
}
}
4. Gestió de Tokens
4.1. Flux de Refresh Automàtic
class AuthInterceptor(
private val tokenStorage: SecureTokenStorage,
private val authService: AuthService,
private val context: Context
) : Interceptor {
private val lock = ReentrantLock()
override fun intercept(chain: Interceptor.Chain): Response {
val token = tokenStorage.getAccessToken(context)
val request = if (token != null) {
chain.request().newBuilder()
.header("Authorization", "Bearer $token")
.build()
} else {
chain.request()
}
var response = chain.proceed(request)
// Si token expirat, intentar refresh
if (response.code == 401 && !request.url.encodedPath.contains("/auth/")) {
response.close()
val newToken = refreshTokenSafely()
if (newToken != null) {
val newRequest = request.newBuilder()
.header("Authorization", "Bearer $newToken")
.build()
response = chain.proceed(newRequest)
}
}
return response
}
private fun refreshTokenSafely(): String? {
// Evitar múltiples refreshes simultanis
if (!lock.tryLock()) {
lock.lock() // Esperar que l'altre acabi
try {
return tokenStorage.getAccessToken(context) // Usar token refrescat
} finally {
lock.unlock()
}
}
try {
val refreshToken = tokenStorage.getRefreshToken(context) ?: return null
val newTokens = authService.refreshToken(refreshToken).execute().body()
?: return null
tokenStorage.saveTokens(
context,
newTokens.accessToken,
newTokens.refreshToken,
newTokens.expiresAt
)
return newTokens.accessToken
} catch (e: Exception) {
// Refresh fallit, forçar logout
tokenStorage.clearTokens(context)
// Notificar UI per redirigir a login
return null
} finally {
lock.unlock()
}
}
}
4.2. Logout Segur
suspend fun logout(context: Context) {
withContext(Dispatchers.IO) {
try {
// Notificar servidor (opcional però recomanat)
val token = SecureTokenStorage.getAccessToken(context)
if (token != null) {
authService.logout()
}
} catch (e: Exception) {
// Ignorar errors de xarxa
} finally {
// SEMPRE netejar local
SecureTokenStorage.clearTokens(context)
// Netejar altres dades sensibles
OfflineDataManager.clearUserData(context)
}
}
}
5. Play Integrity API
5.1. Quan Usar
| Operació | Play Integrity |
|---|---|
| Login normal | ❌ No |
| Ownership claim | ✅ Sí |
| Canvi de rol | ✅ Sí |
| Creació de contingut | ⚠️ Considerar |
| Consultes | ❌ No |
5.2. Implementació Android
class IntegrityManager(private val context: Context) {
private val integrityManager = IntegrityManagerFactory.create(context)
suspend fun getIntegrityToken(nonce: String): String? {
return suspendCancellableCoroutine { continuation ->
val request = IntegrityTokenRequest.builder()
.setNonce(nonce)
.build()
integrityManager.requestIntegrityToken(request)
.addOnSuccessListener { response ->
continuation.resume(response.token())
}
.addOnFailureListener { e ->
Log.e("Integrity", "Failed to get integrity token", e)
continuation.resume(null)
}
}
}
}
// Ús en operacions sensibles
suspend fun claimOwnership(choreographerId: Long) {
val nonce = UUID.randomUUID().toString()
val integrityToken = integrityManager.getIntegrityToken(nonce)
if (integrityToken == null) {
throw SecurityException("No s'ha pogut verificar el dispositiu")
}
api.claimOwnership(
ClaimRequest(
choreographerId = choreographerId,
integrityToken = integrityToken,
nonce = nonce
)
)
}
5.3. Verificació Backend
@Service
public class PlayIntegrityService {
@Value("${google.play.integrity.decryption-key}")
private String decryptionKey;
@Value("${google.play.integrity.verification-key}")
private String verificationKey;
public IntegrityVerdict verifyToken(String token, String expectedNonce) {
try {
DecryptionKeySpec decryptionKeySpec = new DecryptionKeySpec(
Base64.getDecoder().decode(decryptionKey)
);
// Desxifrar i verificar
IntegrityTokenPayload payload = IntegrityTokenPayload.parseFrom(
decrypt(token, decryptionKeySpec)
);
// Verificar nonce
if (!payload.getRequestDetails().getNonce().equals(expectedNonce)) {
return new IntegrityVerdict(false, "Nonce mismatch");
}
// Verificar package name
if (!payload.getAppIntegrity().getPackageName().equals("com.linedance")) {
return new IntegrityVerdict(false, "Invalid package");
}
// Verificar verdict del dispositiu
DeviceIntegrity deviceIntegrity = payload.getDeviceIntegrity();
if (!deviceIntegrity.getDeviceRecognitionVerdictList()
.contains("MEETS_DEVICE_INTEGRITY")) {
return new IntegrityVerdict(false, "Device integrity failed");
}
return new IntegrityVerdict(true, null);
} catch (Exception e) {
log.error("Integrity verification failed", e);
return new IntegrityVerdict(false, "Verification error");
}
}
}
6. Registre de Dispositius (Roadmap)
6.1. Concepte
Permetre a l'usuari veure i gestionar dispositius on ha iniciat sessió:
data class DeviceRegistration(
val deviceId: String, // Identificador únic (no IMEI)
val deviceName: String, // Model + nom personalitzat
val platform: String, // "android"
val appVersion: String,
val lastActive: Instant,
val registeredAt: Instant
)
6.2. Generació d'ID de Dispositiu
object DeviceIdManager {
private const val PREF_KEY = "device_id"
fun getOrCreateDeviceId(context: Context): String {
val prefs = SecureTokenStorage.getEncryptedPrefs(context)
var deviceId = prefs.getString(PREF_KEY, null)
if (deviceId == null) {
deviceId = UUID.randomUUID().toString()
prefs.edit { putString(PREF_KEY, deviceId) }
}
return deviceId
}
}
7. Checklist Android
Per Release
- [ ] Min version configurada al backend
- [ ] Tokens a EncryptedSharedPreferences
- [ ] ProGuard/R8 configurat
- [ ] Dades offline xifrades
- [ ] Play Integrity per operacions sensibles
- [ ] Certificate pinning (si aplica)
- [ ] Logs sense dades sensibles en release
Per Feature Nova
- [ ] Dades sensibles xifrades localment
- [ ] Tokens NO logejats
- [ ] Errors de xarxa gestionats
- [ ] Logout neteja dades locals
8. Referències
- Android Security Best Practices
- EncryptedSharedPreferences
- Play Integrity API
- In-App Updates
- SQLCipher
Historial de Canvis
| Data | Versió | Canvis |
|---|---|---|
| 2024-12 | 1.0 | Document inicial amb ADR per storage |