Add encrypted notification log storage foundation

This commit is contained in:
ajp_anton
2026-07-23 08:03:19 +00:00
parent 022fb5220f
commit 23af171c29
6 changed files with 271 additions and 0 deletions
@@ -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,
)
@@ -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<NotificationLogEntry> = 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<NotificationLogEntry>) = synchronized(lock) {
write(entries)
}
fun clear() = synchronized(lock) {
file.delete()
}
private fun write(entries: List<NotificationLogEntry>) {
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
}
}
@@ -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"
}
}
@@ -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<NotificationLogEntry>): 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<NotificationLogEntry> {
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"),
)
}
}
}
@@ -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,
}