Post-quantum key agreement on Android
A session is post-quantum only when both ends are, so with every Android device registering `proto: 1` the iPhone implementation protected approximately no real conversation. The property was true of the code and false of the product. This is the other half. ML-KEM-768 over Bouncy Castle's low-level `pqc.crypto.mlkem`, the same way Ed25519 already uses its `rfc8032` class: not the JCA provider, which is awkward to register on Android and which R8 cannot shrink. The platform offers nothing here — Conscrypt has no ML-KEM at any API level — and hand-rolling a lattice KEM to avoid a dependency would be a far worse trade than the one Ed25519 already made. No new dependency: the jar was already in the build for Ed25519. Keys are stored as the 64-byte FIPS 203 seed, never the expanded decapsulation key — 64 bytes in the vault instead of 2400, and the same representation iOS persists, which is what lets a history export cross platforms. That is also the thing most likely to be silently wrong, so it is checked against the published vector rather than against another run of our own code: the seed in `pqx3dh-v2.json` derives the public key the normative JavaScript engine derived, and the full responder side reproduces the published session key and associated data byte for byte. Everything else here would pass with an implementation that is internally consistent and agrees with nobody. Negotiation refuses in three places, and each is a substitution somebody would otherwise get for free. A KEM prekey whose signature does not verify is treated as ABSENT, so a relay handing out a key of its own drops the session to v1 rather than to its key — the same substitution `spk_sig` stops classically, and worth more here because it buys the whole post-quantum half. A `proto` claim with no `caps_sig` reads as v1, so a relay cannot advertise a v2 device as v1-only and strip the post-quantum half off every new session. And the one-time id is signed big-endian and inside the signature, so a renumbered key falls back to the last-resort one instead of being spent. Both pools are refilled together, and that is not tidiness. They drain at the same rate — one key per new session — so a refill that topped up only the classical pool would let the post-quantum one run dry and push every later session onto the last-resort key, every one of them sharing a single KEM key, with nothing anywhere reporting a downgrade. Mixed fleets work in both directions and a spent prekey is not an error: a duplicate delivery of a v2 prekey envelope names a one-time key this device has already consumed, and throwing there would turn a retransmission into a dead conversation. Two test fixtures had to say what they are rather than answer by order. Sending now asks for a transparency-log head on its own, so a queue matched purely by position handed that request the device listing and failed on a parse error nowhere near the cause. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
91f58956cf
commit
444ebf0862
@@ -69,6 +69,7 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
|
||||
@@ -485,7 +485,12 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
// Publishing prekeys is what makes this phone reachable. A device that
|
||||
// registers without them can send and can never be started with.
|
||||
val preKeys = device.generateOneTimePreKeys(20)
|
||||
relay.registerDevice(device, preKeys)
|
||||
// 🔴 **The same size as the classical pool, not a smaller one.** Both
|
||||
// drain at one key per new session, so a shorter post-quantum pool
|
||||
// empties first and pushes every later session onto the last-resort key
|
||||
// — every one of them then sharing a single KEM key, silently.
|
||||
val pqPreKeys = device.generatePQOneTimePreKeys(20)
|
||||
relay.registerDevice(device, preKeys, pqPreKeys)
|
||||
vault.saveIdentity(device)
|
||||
|
||||
// 🔴 **Register the address, don't just derive it.** Deriving it makes
|
||||
|
||||
@@ -45,7 +45,27 @@ class OutboxTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@BeforeTest fun start() { server = MockWebServer().also { it.start() } }
|
||||
/**
|
||||
* A relay from before §19, answering the queue for everything else.
|
||||
*
|
||||
* ⚠️ Not incidental setup. Sending now asks for a transparency-log head on
|
||||
* its own, because key checking is automatic on any relay that serves one —
|
||||
* so a queue matched purely by ORDER would hand the head request the device
|
||||
* listing and fail on a parse error miles from the cause. This says plainly
|
||||
* what the fixture is: a deployment with no log, where these tests are
|
||||
* about the outbox and nothing else.
|
||||
*/
|
||||
private class NoKeyLog : okhttp3.mockwebserver.QueueDispatcher() {
|
||||
override fun dispatch(request: okhttp3.mockwebserver.RecordedRequest): MockResponse =
|
||||
// `contains`, not `startsWith`: every route sits under the REST
|
||||
// mount, so the recorded path begins with that prefix.
|
||||
if ("/e2e/log/" in request.path.orEmpty()) MockResponse().setResponseCode(404)
|
||||
else super.dispatch(request)
|
||||
}
|
||||
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer().also { it.dispatcher = NoKeyLog(); it.start() }
|
||||
}
|
||||
@AfterTest fun stop() { server.shutdown() }
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
|
||||
Reference in New Issue
Block a user