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:
Claude
2026-09-22 17:51:57 +00:00
parent bc12254f3d
commit 44d96d0667
3 changed files with 149 additions and 30 deletions
@@ -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? =
@@ -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
@@ -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()