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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user