Add encrypted notification log storage foundation
This commit is contained in:
@@ -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,
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user