fix(marmot): use GCMParameterSpec for decrypt + stop auto-deleting groups on restore failure

Logs from the real device showed that with the previous fixes save and
listGroups now work correctly (878 → 1162 bytes written, file found
after restart), but decrypt was throwing:

    InvalidAlgorithmParameterException: Only GCMParameterSpec supported
        at AndroidKeyStoreAuthenticatedAESCipherSpi$GCM.initAlgorithmSpecificParameters

AndroidKeyStore's authenticated AES/GCM cipher rejects plain
IvParameterSpec and requires an explicit GCMParameterSpec with the
auth tag length. Switched decrypt() to use GCMParameterSpec(128, iv).

While chasing this I also noticed that MlsGroupManager.restoreAll
was **deleting** the stored group on any exception from load(), on
the assumption that it must be corrupted. This turned a transient
decrypt bug into permanent data loss — the user's group file was
wiped during the first broken restart. Change the catch block to
log and skip instead of delete, so after a fix the next restart
can still recover the data.

https://claude.ai/code/session_014EKS8JBwSpap34aM6FnYLm
This commit is contained in:
Claude
2026-04-15 19:27:36 +00:00
parent 4d5861e581
commit c04d3d0bf0
2 changed files with 12 additions and 8 deletions
@@ -29,7 +29,7 @@ import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.GCMParameterSpec
class KeyStoreEncryption {
companion object {
@@ -42,6 +42,7 @@ class KeyStoreEncryption {
private const val PURPOSE = KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
private const val KEY_ALIAS = "AMETHYST_AES_KEY"
private const val GCM_IV_LENGTH = 12
private const val GCM_TAG_LENGTH_BITS = 128
}
private val cipher = Cipher.getInstance(TRANSFORMATION)
@@ -106,12 +107,12 @@ class KeyStoreEncryption {
fun decrypt(bytes: ByteArray): ByteArray? {
try {
// Extracts IV and decrypts the data. GCM mode uses a 12-byte IV,
// which is what cipher.iv returns in encrypt() — not the AES block
// size (16), which is what cipher.blockSize would return.
// Extract the 12-byte GCM IV prefix and decrypt the remainder. The
// AndroidKeyStore cipher only accepts GCMParameterSpec (not a plain
// IvParameterSpec), so we must pass the 128-bit auth tag length.
val iv = bytes.copyOfRange(0, GCM_IV_LENGTH)
val data = bytes.copyOfRange(GCM_IV_LENGTH, bytes.size)
cipher.init(Cipher.DECRYPT_MODE, getKey(), IvParameterSpec(iv))
cipher.init(Cipher.DECRYPT_MODE, getKey(), GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv))
return cipher.doFinal(data)
} catch (e: Exception) {
Log.e(TAG, "decrypt() failed (input ${bytes.size} bytes): ${e.message}", e)
@@ -127,13 +127,16 @@ class MlsGroupManager(
Log.d(TAG) { "restoreAll(): restored ${retained.size} retained epochs for $nostrGroupId" }
}
} catch (e: Exception) {
// Corrupted state — log and remove it so it doesn't block future joins
// Could be genuinely corrupted state, or a transient bug
// (e.g. wrong cipher param spec). Skip but DO NOT delete —
// a future restart after a fix should still be able to
// recover the group. If it really is corrupted the user
// can explicitly leave/delete the group from the UI.
Log.e(
TAG,
"restoreAll(): Corrupted state for group $nostrGroupId, DELETING: ${e.message}",
"restoreAll(): failed to restore group $nostrGroupId, skipping (file preserved): ${e.message}",
e,
)
store.delete(nostrGroupId)
}
}
Log.d(TAG) { "restoreAll(): finished with ${groups.size} active groups in memory" }