Android: the app, and the two things only a phone could tell us
There is now a Flamenet Messenger app that installs, launches, creates an account on a self-hosted relay, registers a device, publishes prekeys, and stores all of it in an Android Keystore-backed vault. Verified on an emulator through the actual screens, not by unit test. 92 JVM tests, 37 cross-engine, 9 on-device, 3 against live relays. Two findings, both of which the JVM suite was structurally incapable of producing. **Android has no ordinary Ed25519 provider.** `KeyPairGenerator.Ed25519` and `KeyFactory.Ed25519` come from `AndroidKeyStore` and from nowhere else -- probed on API 35 rather than assumed -- and Keystore only makes keys it keeps. Every Ed25519 key in this engine is one we must hold: a device identity in our own vault, an account key derived from a passphrase. So the JCA route could never have worked. It compiled, passed 81 tests on a laptop, and threw "Not initialized" on the first launch on a phone. Worse, it was LATENT: a wrong password failed before any key was minted, so the error path worked perfectly while the happy path was broken. Ed25519 now uses BouncyCastle's `math.ec.rfc8032` core directly -- the engine's one real dependency, present because the platform leaves a hole, and a strictly better answer than the non-constant-time keygen it replaces. ProviderProbeTest records the measurement so nobody guesses again. **The prekey field is `prekeys`, not `opks`.** I guessed. The relay accepted the registration, returned 200, and stored nothing -- so the device was published and could not be started with, a failure with no error anywhere. Caught by a live test against the throwaway fleet, which exists because the same path through the emulator takes four minutes to say "timed out" and nothing about where. Two testing traps, recorded where they bite: An indeterminate progress spinner animates for ever, so Compose's test clock never goes idle and `waitUntil` never evaluates its condition -- it fails on the full timeout, reading as "sign-up is broken" when sign-up works. Auto-advance is now disabled around the wait only; disabling it for the whole test is the next mistake, because the app opens on a spinner while it reads the vault. Wiping the Keystore in `@Before` deletes the key the already-launched activity is holding, and every later write fails against a key that no longer exists. The test signs out through the UI instead, which is both safe and what a person does. Failure messages now include what the screen was showing. A bare timeout sends you to the network, the relay and the crypto in turn while the app has been displaying the reason the whole time. Also corrects a note that said this Mac had no Android SDK. It does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b1ba9cdc58
commit
5f983c1455
@@ -0,0 +1,143 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import org.json.JSONObject
|
||||
import java.security.SecureRandom
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
/**
|
||||
* Everything the app must remember, encrypted at rest.
|
||||
*
|
||||
* ⚠️ **This deliberately does not use [Aead].** `Aead` takes raw key bytes, and
|
||||
* the key here comes from the Android Keystore, which never hands out its key
|
||||
* material — that is the entire reason to use it. A design that needed the raw
|
||||
* bytes would quietly have to keep them somewhere, which is the thing being
|
||||
* avoided. So this works with a `SecretKey` handle and the platform `Cipher`.
|
||||
*
|
||||
* ⚠️ **Every record's name is bound into the AAD.** Without that, a blob is
|
||||
* portable between slots: someone with file access could paste the session for
|
||||
* one contact into another contact's slot, and the app would decrypt it
|
||||
* happily and attribute messages to the wrong person. The name is not a
|
||||
* filename here, it is part of the ciphertext's meaning.
|
||||
*
|
||||
* ⚠️ **Deleting has to actually delete.** "Deleted means deleted" is a promise
|
||||
* the product makes, so [forget] removes the record rather than marking it, and
|
||||
* [forgetEverything] takes the identity with it.
|
||||
*/
|
||||
class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
|
||||
/** Wherever bytes live. A directory on a phone, a map in a test. */
|
||||
interface Storage {
|
||||
fun read(name: String): ByteArray?
|
||||
fun write(name: String, bytes: ByteArray)
|
||||
fun delete(name: String)
|
||||
fun names(): List<String>
|
||||
}
|
||||
|
||||
class Corrupt(name: String, cause: Throwable?) :
|
||||
Exception("the vault record '$name' did not open", cause)
|
||||
|
||||
companion object {
|
||||
private const val NONCE_LEN = 12
|
||||
private const val TAG_BITS = 128
|
||||
|
||||
private const val IDENTITY = "identity"
|
||||
private const val WATERMARK = "watermark"
|
||||
private const val SESSION_PREFIX = "session/"
|
||||
|
||||
private val random = SecureRandom()
|
||||
}
|
||||
|
||||
// -- Bytes ---------------------------------------------------------------
|
||||
|
||||
private fun seal(name: String, plaintext: ByteArray): ByteArray {
|
||||
val nonce = ByteArray(NONCE_LEN).also(random::nextBytes)
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(TAG_BITS, nonce))
|
||||
cipher.updateAAD(name.toByteArray(Charsets.UTF_8))
|
||||
return nonce + cipher.doFinal(plaintext)
|
||||
}
|
||||
|
||||
private fun open(name: String, box: ByteArray): ByteArray {
|
||||
if (box.size <= NONCE_LEN) throw Corrupt(name, null)
|
||||
return try {
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
cipher.init(
|
||||
Cipher.DECRYPT_MODE, key,
|
||||
GCMParameterSpec(TAG_BITS, box.copyOfRange(0, NONCE_LEN)),
|
||||
)
|
||||
cipher.updateAAD(name.toByteArray(Charsets.UTF_8))
|
||||
cipher.doFinal(box, NONCE_LEN, box.size - NONCE_LEN)
|
||||
} catch (e: Exception) {
|
||||
// Deliberately one exception for every cause. Which of "wrong key",
|
||||
// "wrong slot" and "flipped bit" it was is information an attacker
|
||||
// would like and the user cannot act on.
|
||||
throw Corrupt(name, e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readJson(name: String): JSONObject? =
|
||||
storage.read(name)?.let { JSONObject(String(open(name, it), Charsets.UTF_8)) }
|
||||
|
||||
private fun writeJson(name: String, json: JSONObject) =
|
||||
storage.write(name, seal(name, json.toString().toByteArray(Charsets.UTF_8)))
|
||||
|
||||
// -- Identity ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* ⚠️ Returning null here means "this is a new install", and the caller will
|
||||
* mint a fresh device — which every contact sees as "security keys
|
||||
* changed", the warning that is supposed to mean an attack. So a record
|
||||
* that exists but will not open must throw rather than read as absent.
|
||||
*/
|
||||
fun loadIdentity(): DeviceIdentity? = readJson(IDENTITY)?.let(DeviceIdentity::fromArchive)
|
||||
|
||||
fun saveIdentity(identity: DeviceIdentity) = writeJson(IDENTITY, identity.archive())
|
||||
|
||||
fun hasIdentity(): Boolean = storage.read(IDENTITY) != null
|
||||
|
||||
// -- Sessions ------------------------------------------------------------
|
||||
|
||||
private fun sessionName(peerDeviceId: String) = SESSION_PREFIX + peerDeviceId
|
||||
|
||||
fun loadSession(peerDeviceId: String): E2ESession? =
|
||||
readJson(sessionName(peerDeviceId))?.let(E2ESession::fromArchive)
|
||||
|
||||
fun saveSession(peerDeviceId: String, session: E2ESession) =
|
||||
writeJson(sessionName(peerDeviceId), session.archive())
|
||||
|
||||
fun knownPeers(): List<String> =
|
||||
storage.names().filter { it.startsWith(SESSION_PREFIX) }.map { it.removePrefix(SESSION_PREFIX) }
|
||||
|
||||
// -- Inbox watermark -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* The highest inbox id already **decrypted and stored**.
|
||||
*
|
||||
* ⚠️ Advance it on receipt rather than after storing and a crash mid-batch
|
||||
* loses the rest of the batch silently — the relay will never offer those
|
||||
* ids again.
|
||||
*/
|
||||
var inboxWatermark: Long
|
||||
get() = readJson(WATERMARK)?.optLong("id", 0) ?: 0
|
||||
set(value) = writeJson(WATERMARK, JSONObject().put("id", value))
|
||||
|
||||
// -- Forgetting ----------------------------------------------------------
|
||||
|
||||
/** Forget one conversation. The identity and every other session survive. */
|
||||
fun forget(peerDeviceId: String) = storage.delete(sessionName(peerDeviceId))
|
||||
|
||||
/**
|
||||
* Forget everything, identity included.
|
||||
*
|
||||
* This is sign-out and account deletion. Leaving the identity behind would
|
||||
* mean the next person to use the phone inherits the device the relay still
|
||||
* has published — and messages addressed to it.
|
||||
*/
|
||||
fun forgetEverything() = storage.names().forEach(storage::delete)
|
||||
|
||||
/** Forget conversations but keep this device's identity. */
|
||||
fun forgetAllSessions() =
|
||||
storage.names().filter { it.startsWith(SESSION_PREFIX) }.forEach(storage::delete)
|
||||
}
|
||||
Reference in New Issue
Block a user