mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
perf(marmot): fold only the loose tail, and report the keystore's security level
Two follow-ups to the append-only message log, both about the same thing: how much data a hardware-backed cipher has to move. The measurement that frames it: a secure element runs AES-GCM at roughly 68 KB/s (1 MiB in ~15s on a Pixel 8, per the KeyDroid study), against tens of MB/s for the TEE. At that rate a 1000-message conversation is ~3.7s of pure cipher time per pass over its log. Folding now re-encrypts only the run it collapses. It previously rewrote the whole log every 200 appends, which was already 200x better than the format before it but left one send in 200 paying the old cost - an unpredictable multi-second stall on a secure-element device, which reads worse than uniform slowness. The already-folded prefix is now copied across as ciphertext and only the loose run goes through the cipher, so no append ever pays for the whole history. There is a test for exactly that: the bytes of the previous fold must survive verbatim, which they cannot if they were re-encrypted, since every call takes a fresh nonce. The fold still goes through a temp file and a rename. Truncating in place and appending afterwards would be cheaper and would lose the entire loose run if the process died in between. Alongside it, KeyStoreEncryption now logs once per process where AMETHYST_AES_KEY actually lives. This is not knowable from the code: createKeyStrongBoxIfAvailable is tried first, so a device with a secure element gets one, and getEntry afterwards returns whatever that device created, possibly years ago under different code. Anything bulk that is slow on one device and fine on another is explained by that line, and without it the question stays a guess. What this does NOT do is move bulk crypto off the keystore key. Opening a long conversation still decrypts its whole log, which on a secure element is seconds. Fixing that means envelope encryption - wrapping a random data key with the keystore key and doing bulk work in software, the way Jetpack Security's EncryptedFile wraps a Tink keyset - which moves a data key into process memory and needs a migration for every existing install. That is a security tradeoff to decide deliberately, not to slip into a perf commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019R58HADiyhsipTye538fWs
This commit is contained in:
+39
-1
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model.preferences
|
||||
|
||||
import android.os.Build
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyInfo
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.security.keystore.StrongBoxUnavailableException
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
@@ -29,6 +30,7 @@ import java.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.SecretKeyFactory
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
class KeyStoreEncryption {
|
||||
@@ -67,7 +69,43 @@ class KeyStoreEncryption {
|
||||
|
||||
private fun loadOrCreateKey(): SecretKey {
|
||||
val existingKey = keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry
|
||||
return existingKey?.secretKey ?: createKey()
|
||||
return (existingKey?.secretKey ?: createKey()).also { logSecurityLevel(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Say once, per process, where this key actually lives.
|
||||
*
|
||||
* It decides the cost of everything encrypted at rest here, and it is not
|
||||
* knowable from the code: [createKeyStrongBoxIfAvailable] is tried first, so
|
||||
* a device with a secure element gets one, and `getEntry` then returns
|
||||
* whatever that device created — possibly years ago, under different code.
|
||||
*
|
||||
* The difference is not small. A secure element runs AES-GCM at roughly
|
||||
* 68 KB/s (1 MiB in ~15s on a Pixel 8), against tens of MB/s for the TEE.
|
||||
* Anything bulk that shows up slow on one device and fine on another is
|
||||
* explained by this line.
|
||||
*/
|
||||
private fun logSecurityLevel(key: SecretKey) {
|
||||
try {
|
||||
val factory = SecretKeyFactory.getInstance(key.algorithm, ANDROID_KEY_STORE)
|
||||
val info = factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo
|
||||
val level =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
when (info.securityLevel) {
|
||||
KeyProperties.SECURITY_LEVEL_STRONGBOX -> "STRONGBOX (bulk crypto here is ~68 KB/s)"
|
||||
KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT -> "TEE"
|
||||
KeyProperties.SECURITY_LEVEL_SOFTWARE -> "SOFTWARE"
|
||||
else -> "UNKNOWN(${info.securityLevel})"
|
||||
}
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
if (info.isInsideSecureHardware) "SECURE_HARDWARE (TEE or StrongBox)" else "SOFTWARE"
|
||||
}
|
||||
Log.i(TAG) { "$KEY_ALIAS security level: $level" }
|
||||
} catch (e: Exception) {
|
||||
// Purely diagnostic — never let it interfere with having a key.
|
||||
Log.d(TAG) { "Could not determine the security level of $KEY_ALIAS: ${e.message}" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun createKeyStrongBoxIfAvailable(): SecretKey? =
|
||||
|
||||
+83
-27
@@ -69,15 +69,33 @@ class EncryptedAppendLog(
|
||||
private class LogState(
|
||||
val entries: MutableList<String>,
|
||||
val seen: MutableSet<String>,
|
||||
var segments: Int,
|
||||
/** False when the file has no header yet: it is new, or still in the old format. */
|
||||
var headed: Boolean,
|
||||
/** Byte length of the part that has already been folded; the loose run starts here. */
|
||||
var foldedLength: Long,
|
||||
/** Segments appended since the last fold. */
|
||||
var looseSegments: Int,
|
||||
/** Entries carried by those segments. */
|
||||
var looseEntries: Int,
|
||||
)
|
||||
|
||||
private val logs = mutableMapOf<String, LogState>()
|
||||
|
||||
private fun stateFor(file: File): LogState =
|
||||
logs.getOrPut(file.absolutePath) {
|
||||
val (entries, segments) = decodeFile(file)
|
||||
LogState(entries.toMutableList(), entries.toMutableSet(), segments)
|
||||
val (entries, headed) = decodeFile(file)
|
||||
// Everything already on disk counts as folded. Whatever loose
|
||||
// segments a previous session left behind stay where they are —
|
||||
// re-folding them would re-encrypt bytes that are already encrypted,
|
||||
// which is the cost this whole design exists to avoid.
|
||||
LogState(
|
||||
entries = entries.toMutableList(),
|
||||
seen = entries.toMutableSet(),
|
||||
headed = headed,
|
||||
foldedLength = if (headed) file.length() else 0L,
|
||||
looseSegments = 0,
|
||||
looseEntries = 0,
|
||||
)
|
||||
}
|
||||
|
||||
/** Every entry in [file], oldest first. */
|
||||
@@ -98,17 +116,59 @@ class EncryptedAppendLog(
|
||||
state.entries.add(entry)
|
||||
state.seen.add(entry)
|
||||
|
||||
// Either there is no header to append after (a file that does not
|
||||
// exist yet, or one still in the old format), or too many loose
|
||||
// segments have piled up to keep reads cheap. A rewrite fixes both,
|
||||
// and is what lays the header down.
|
||||
if (state.segments == UNSEGMENTED || state.segments >= compactAfterSegments) {
|
||||
// No header to append after — a file that does not exist yet, or one
|
||||
// still in the old format. A rewrite lays one down.
|
||||
if (!state.headed) {
|
||||
rewrite(file, state.entries.toList())
|
||||
return
|
||||
}
|
||||
|
||||
appendSegment(file, listOf(entry))
|
||||
state.looseSegments += 1
|
||||
state.looseEntries += 1
|
||||
|
||||
if (state.looseSegments >= compactAfterSegments) foldLooseTail(file, state)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse the loose run into one segment, re-encrypting only that run.
|
||||
*
|
||||
* The prefix is copied across as ciphertext, byte for byte. That is the
|
||||
* point: folding exists to bound how many segments a read has to open, and
|
||||
* re-encrypting the whole log to achieve it would cost more than the
|
||||
* segments ever did — on a hardware-backed cipher, enough to stall a send
|
||||
* for seconds once a conversation is long enough.
|
||||
*
|
||||
* Written through a temp file and renamed, so a crash mid-fold leaves the
|
||||
* previous log intact. Truncating in place and appending afterwards would
|
||||
* be cheaper and would lose every loose entry if the process died in
|
||||
* between.
|
||||
*/
|
||||
private fun foldLooseTail(
|
||||
file: File,
|
||||
state: LogState,
|
||||
) {
|
||||
if (state.looseEntries == 0) return
|
||||
val prefix = file.readBytes().copyOfRange(0, state.foldedLength.toInt())
|
||||
val folded = encrypt(encodeEntries(state.entries.takeLast(state.looseEntries)))
|
||||
|
||||
val out = ByteArray(prefix.size + 4 + folded.size)
|
||||
prefix.copyInto(out, 0)
|
||||
lengthPrefix(folded.size).copyInto(out, prefix.size)
|
||||
folded.copyInto(out, prefix.size + 4)
|
||||
atomicWrite(file, out)
|
||||
|
||||
state.foldedLength = out.size.toLong()
|
||||
state.looseSegments = 0
|
||||
state.looseEntries = 0
|
||||
}
|
||||
|
||||
private fun appendSegment(
|
||||
file: File,
|
||||
entries: List<String>,
|
||||
) {
|
||||
file.parentFile?.mkdirs()
|
||||
val segment = encrypt(encodeEntries(listOf(entry)))
|
||||
val segment = encrypt(encodeEntries(entries))
|
||||
FileOutputStream(file, true).use { out ->
|
||||
out.write(lengthPrefix(segment.size))
|
||||
out.write(segment)
|
||||
@@ -116,7 +176,6 @@ class EncryptedAppendLog(
|
||||
// message the UI has already shown as sent.
|
||||
out.fd.sync()
|
||||
}
|
||||
state.segments += 1
|
||||
}
|
||||
|
||||
/** Replace the whole log with [entries], as a single segment. */
|
||||
@@ -133,12 +192,18 @@ class EncryptedAppendLog(
|
||||
segment.copyInto(out, MAGIC.size + 4)
|
||||
atomicWrite(file, out)
|
||||
|
||||
val state = logs.getOrPut(file.absolutePath) { LogState(mutableListOf(), mutableSetOf(), 1) }
|
||||
val state =
|
||||
logs.getOrPut(file.absolutePath) {
|
||||
LogState(mutableListOf(), mutableSetOf(), headed = true, foldedLength = 0L, looseSegments = 0, looseEntries = 0)
|
||||
}
|
||||
state.entries.clear()
|
||||
state.entries.addAll(entries)
|
||||
state.seen.clear()
|
||||
state.seen.addAll(entries)
|
||||
state.segments = 1
|
||||
state.headed = true
|
||||
state.foldedLength = out.size.toLong()
|
||||
state.looseSegments = 0
|
||||
state.looseEntries = 0
|
||||
}
|
||||
|
||||
/** Drop the in-memory cache for [file]; call when the file is deleted. */
|
||||
@@ -146,22 +211,21 @@ class EncryptedAppendLog(
|
||||
logs.remove(file.absolutePath)
|
||||
}
|
||||
|
||||
/** Every entry in [file], and how many segments they came from. */
|
||||
private fun decodeFile(file: File): Pair<List<String>, Int> {
|
||||
/** Every entry in [file], and whether the file already carries a header. */
|
||||
private fun decodeFile(file: File): Pair<List<String>, Boolean> {
|
||||
// A file that does not exist yet has no header, so the first append has
|
||||
// to write one rather than tack a bare segment onto nothing.
|
||||
if (!file.exists()) return emptyList<String>() to UNSEGMENTED
|
||||
if (!file.exists()) return emptyList<String>() to false
|
||||
val bytes = file.readBytes()
|
||||
|
||||
if (!bytes.startsWithMagic()) {
|
||||
// The older format: the file is one encrypted blob and nothing else.
|
||||
val plain = decrypt(bytes) ?: return emptyList<String>() to UNSEGMENTED
|
||||
return decodeEntries(plain) to UNSEGMENTED
|
||||
val plain = decrypt(bytes) ?: return emptyList<String>() to false
|
||||
return decodeEntries(plain) to false
|
||||
}
|
||||
|
||||
val result = ArrayList<String>()
|
||||
var offset = MAGIC.size
|
||||
var segments = 0
|
||||
while (offset + 4 <= bytes.size) {
|
||||
val encLen = readInt(bytes, offset)
|
||||
offset += 4
|
||||
@@ -172,10 +236,9 @@ class EncryptedAppendLog(
|
||||
if (encLen <= 0 || offset + encLen > bytes.size) break
|
||||
val plain = decrypt(bytes.copyOfRange(offset, offset + encLen))
|
||||
offset += encLen
|
||||
segments += 1
|
||||
if (plain != null) result.addAll(decodeEntries(plain))
|
||||
}
|
||||
return result to segments.coerceAtLeast(1)
|
||||
return result to true
|
||||
}
|
||||
|
||||
private fun encodeEntries(entries: List<String>): ByteArray {
|
||||
@@ -258,13 +321,6 @@ class EncryptedAppendLog(
|
||||
/** Marks the segmented format; a file without it predates it. */
|
||||
private val MAGIC = "MRMTLOG2".encodeToByteArray()
|
||||
|
||||
/**
|
||||
* Segment count standing for "this file has no header yet" — either it
|
||||
* does not exist, or it was written by the original whole-blob format.
|
||||
* The next append rewrites it rather than appending into nothing.
|
||||
*/
|
||||
private const val UNSEGMENTED = -1
|
||||
|
||||
private const val COMPACT_AFTER_SEGMENTS = 200
|
||||
|
||||
private const val MAX_ENTRIES = 1_000_000
|
||||
|
||||
+27
-2
@@ -104,11 +104,36 @@ class EncryptedAppendLogTest {
|
||||
written.forEach { log.append(file, it) }
|
||||
|
||||
assertContentEquals(written, cipher().readAll(file))
|
||||
// Four appends per compaction, so the file can never carry 20 segments'
|
||||
// worth of framing — it is the bound on read cost that matters here.
|
||||
// Folding every four appends leaves ~5 segments rather than 20, which is
|
||||
// the bound on read cost that matters here.
|
||||
assertTrue(file.length() < 20 * SEGMENT_OVERHEAD_CEILING, "log should have been compacted, was ${file.length()} bytes")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a fold re-encrypts only the loose run, never the prefix`() {
|
||||
val file = tempFile()
|
||||
val log = cipher(compactAfter = 4)
|
||||
|
||||
// The first append lays the header down; the next four are the loose run
|
||||
// that the fourth of them collapses.
|
||||
(1..5).forEach { log.append(file, "first-run-$it") }
|
||||
val afterFirstFold = file.readBytes()
|
||||
|
||||
(1..4).forEach { log.append(file, "second-run-$it") }
|
||||
val afterSecondFold = file.readBytes()
|
||||
|
||||
// The bytes the first fold produced must survive verbatim. If they were
|
||||
// re-encrypted they would differ, since the stand-in cipher — like
|
||||
// AES-GCM — uses a fresh nonce per call. This is the property that keeps
|
||||
// a send from paying for the whole conversation.
|
||||
assertContentEquals(
|
||||
afterFirstFold.toList(),
|
||||
afterSecondFold.copyOfRange(0, afterFirstFold.size).toList(),
|
||||
"folding the tail must copy the already-folded prefix as ciphertext",
|
||||
)
|
||||
assertContentEquals((1..5).map { "first-run-$it" } + (1..4).map { "second-run-$it" }, cipher().readAll(file))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a log written by the original whole-blob format is still readable`() {
|
||||
val file = tempFile()
|
||||
|
||||
Reference in New Issue
Block a user