diff --git a/app/src/main/java/se/ajpanton/notificationlog/data/AesGcmCipher.kt b/app/src/main/java/se/ajpanton/notificationlog/data/AesGcmCipher.kt new file mode 100644 index 0000000..317a46f --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationlog/data/AesGcmCipher.kt @@ -0,0 +1,43 @@ +package se.ajpanton.notificationlog.data + +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** Small, format-neutral AES-GCM codec used for every persisted log payload. */ +class AesGcmCipher( + private val key: SecretKey, + private val secureRandom: SecureRandom = SecureRandom(), +) { + fun encrypt(plainText: ByteArray): EncryptedPayload { + val initializationVector = ByteArray(IV_LENGTH_BYTES).also(secureRandom::nextBytes) + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(TAG_LENGTH_BITS, initializationVector)) + return EncryptedPayload(initializationVector, cipher.doFinal(plainText)) + } + + fun decrypt(payload: EncryptedPayload): ByteArray { + require(payload.initializationVector.size == IV_LENGTH_BYTES) { + "Unexpected AES-GCM initialization-vector length." + } + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init( + Cipher.DECRYPT_MODE, + key, + GCMParameterSpec(TAG_LENGTH_BITS, payload.initializationVector), + ) + return cipher.doFinal(payload.cipherText) + } + + companion object { + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val IV_LENGTH_BYTES = 12 + private const val TAG_LENGTH_BITS = 128 + } +} + +data class EncryptedPayload( + val initializationVector: ByteArray, + val cipherText: ByteArray, +) diff --git a/app/src/main/java/se/ajpanton/notificationlog/data/EncryptedNotificationLogStore.kt b/app/src/main/java/se/ajpanton/notificationlog/data/EncryptedNotificationLogStore.kt new file mode 100644 index 0000000..36e26be --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationlog/data/EncryptedNotificationLogStore.kt @@ -0,0 +1,83 @@ +package se.ajpanton.notificationlog.data + +import android.content.Context +import android.util.AtomicFile +import se.ajpanton.notificationlog.model.NotificationLogEntry +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.FileNotFoundException + +/** + * An atomically replaced encrypted event log. It keeps every persisted log field + * encrypted, including timestamps and package names, while the app is at rest. + */ +class EncryptedNotificationLogStore(context: Context) { + private val lock = Any() + private val file = AtomicFile(context.filesDir.resolve(FILE_NAME)) + private val cipher = AesGcmCipher(LogEncryptionKeyProvider().getOrCreate()) + + fun readAll(): List = synchronized(lock) { + val payload = readPayloadOrNull() ?: return emptyList() + NotificationLogEntryJson.decode(cipher.decrypt(payload)) + } + + fun append(entry: NotificationLogEntry) = synchronized(lock) { + val entries = (readPayloadOrNull()?.let { NotificationLogEntryJson.decode(cipher.decrypt(it)) } ?: emptyList()) + write(entries + entry) + } + + fun replaceAll(entries: List) = synchronized(lock) { + write(entries) + } + + fun clear() = synchronized(lock) { + file.delete() + } + + private fun write(entries: List) { + val payload = cipher.encrypt(NotificationLogEntryJson.encode(entries)) + val output = file.startWrite() + try { + DataOutputStream(BufferedOutputStream(output)).use { stream -> + stream.writeInt(FILE_VERSION) + stream.writeInt(payload.initializationVector.size) + stream.write(payload.initializationVector) + stream.writeInt(payload.cipherText.size) + stream.write(payload.cipherText) + } + file.finishWrite(output) + } catch (error: Exception) { + file.failWrite(output) + throw error + } + } + + private fun readPayloadOrNull(): EncryptedPayload? { + val input = try { + file.openRead() + } catch (_: FileNotFoundException) { + return null + } + DataInputStream(BufferedInputStream(input)).use { stream -> + require(stream.readInt() == FILE_VERSION) { "Unsupported encrypted notification-log version." } + val initializationVector = readByteArray(stream, MAX_IV_BYTES) + val cipherText = readByteArray(stream, MAX_CIPHER_TEXT_BYTES) + return EncryptedPayload(initializationVector, cipherText) + } + } + + private fun readByteArray(stream: DataInputStream, maximumLength: Int): ByteArray { + val length = stream.readInt() + require(length in 1..maximumLength) { "Invalid encrypted notification-log field length." } + return ByteArray(length).also(stream::readFully) + } + + private companion object { + const val FILE_NAME = "notification-log.v1" + const val FILE_VERSION = 1 + const val MAX_IV_BYTES = 32 + const val MAX_CIPHER_TEXT_BYTES = 50 * 1024 * 1024 + } +} diff --git a/app/src/main/java/se/ajpanton/notificationlog/data/LogEncryptionKeyProvider.kt b/app/src/main/java/se/ajpanton/notificationlog/data/LogEncryptionKeyProvider.kt new file mode 100644 index 0000000..4e6ebfa --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationlog/data/LogEncryptionKeyProvider.kt @@ -0,0 +1,34 @@ +package se.ajpanton.notificationlog.data + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import java.security.KeyStore +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey + +/** Creates a non-exportable key in Android Keystore; it is never written into app storage. */ +class LogEncryptionKeyProvider { + fun getOrCreate(): SecretKey { + val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) } + (keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it } + + return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE).run { + init( + KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setRandomizedEncryptionRequired(true) + .build(), + ) + generateKey() + } + } + + private companion object { + const val ANDROID_KEY_STORE = "AndroidKeyStore" + const val KEY_ALIAS = "notification_log_storage_key_v1" + } +} diff --git a/app/src/main/java/se/ajpanton/notificationlog/data/NotificationLogEntryJson.kt b/app/src/main/java/se/ajpanton/notificationlog/data/NotificationLogEntryJson.kt new file mode 100644 index 0000000..71ecf7c --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationlog/data/NotificationLogEntryJson.kt @@ -0,0 +1,38 @@ +package se.ajpanton.notificationlog.data + +import org.json.JSONArray +import org.json.JSONObject +import se.ajpanton.notificationlog.model.NotificationAction +import se.ajpanton.notificationlog.model.NotificationLogEntry + +/** Versioned, private plaintext representation which is encrypted before it reaches disk. */ +object NotificationLogEntryJson { + fun encode(entries: List): ByteArray = JSONArray().apply { + entries.forEach { entry -> + put( + JSONObject() + .put("id", entry.id) + .put("recordedAtEpochMillis", entry.recordedAtEpochMillis) + .put("packageName", entry.packageName) + .put("appName", entry.appName) + .put("action", entry.action.name) + .put("contents", entry.contents), + ) + } + }.toString().encodeToByteArray() + + fun decode(serialized: ByteArray): List { + val array = JSONArray(serialized.decodeToString()) + return List(array.length()) { index -> + val entry = array.getJSONObject(index) + NotificationLogEntry( + id = entry.getString("id"), + recordedAtEpochMillis = entry.getLong("recordedAtEpochMillis"), + packageName = entry.getString("packageName"), + appName = entry.getString("appName"), + action = NotificationAction.valueOf(entry.getString("action")), + contents = if (entry.isNull("contents")) null else entry.getString("contents"), + ) + } + } +} diff --git a/app/src/main/java/se/ajpanton/notificationlog/model/NotificationLogEntry.kt b/app/src/main/java/se/ajpanton/notificationlog/model/NotificationLogEntry.kt new file mode 100644 index 0000000..0e0e3ca --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationlog/model/NotificationLogEntry.kt @@ -0,0 +1,44 @@ +package se.ajpanton.notificationlog.model + +import java.util.UUID + +/** A rendered-notification event, not the mutable notification itself. */ +data class NotificationLogEntry( + val id: String = UUID.randomUUID().toString(), + val recordedAtEpochMillis: Long, + val packageName: String, + val appName: String, + val action: NotificationAction, + val contents: String?, +) + +enum class NotificationAction { + APPEARED, + ALREADY_ACTIVE, + EDITED, + APP_CANCELLED, + APP_CANCELLED_ALL, + USER_DISMISSED, + USER_DISMISSED_ALL, + USER_CLICKED, + LISTENER_DISMISSED, + LISTENER_DISMISSED_ALL, + SNOOZED, + TIMED_OUT, + CHANNEL_BANNED, + CHANNEL_REMOVED, + PACKAGE_BANNED, + PACKAGE_CHANGED, + PACKAGE_SUSPENDED, + PROFILE_TURNED_OFF, + USER_STOPPED, + GROUP_SUMMARY_CANCELLED, + GROUP_OPTIMIZED, + UNAUTOBUNDLED, + BUNDLE_DISMISSED, + CLEAR_DATA, + ASSISTANT_CANCELLED, + LOCKDOWN, + SYSTEM_ERROR, + OTHER_REMOVAL, +} diff --git a/app/src/test/java/se/ajpanton/notificationlog/data/AesGcmCipherTest.kt b/app/src/test/java/se/ajpanton/notificationlog/data/AesGcmCipherTest.kt new file mode 100644 index 0000000..d71b28c --- /dev/null +++ b/app/src/test/java/se/ajpanton/notificationlog/data/AesGcmCipherTest.kt @@ -0,0 +1,29 @@ +package se.ajpanton.notificationlog.data + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.security.SecureRandom +import javax.crypto.KeyGenerator + +class AesGcmCipherTest { + @Test + fun roundTripsDataWithoutLeavingItInCipherText() { + val key = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + val plainText = "private notification content".encodeToByteArray() + + val encrypted = AesGcmCipher(key).encrypt(plainText) + + assertFalse(encrypted.cipherText.decodeToString().contains("private notification content")) + assertTrue(AesGcmCipher(key).decrypt(encrypted).contentEquals(plainText)) + } + + @Test(expected = javax.crypto.AEADBadTagException::class) + fun rejectsTamperedCipherText() { + val key = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + val encrypted = AesGcmCipher(key, SecureRandom()).encrypt("message".encodeToByteArray()) + encrypted.cipherText[0] = (encrypted.cipherText[0].toInt() xor 1).toByte() + + AesGcmCipher(key).decrypt(encrypted) + } +}