Skip to main content

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:

  1. Versió mínima obligatòria
  2. In-App Updates
  3. Emmagatzematge segur offline
  4. Gestió de tokens
  5. 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ÚsComportament
FlexibleUpdates normalsL'usuari pot continuar usant l'app
ImmediateUpdates críticsBloqueja 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

DadesOfflineXifratJustificació
Catàleg de DancesPúblic, millora UX
Catàleg de SongsPúblic
Preferències usuariPot contenir info personal
Tokens (JWT)CRÍTIC
Dades de perfil⚠️ LimitatNomés bàsic
Historial de visualitzacionsPrivat

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


Historial de Canvis

DataVersióCanvis
2024-121.0Document inicial amb ADR per storage