Files
flamenet-messenger-android/engine/src/test/kotlin/io/flamenet/e2e/X3DHTest.kt
T
Paul HittandClaude Opus 5 c893d412d2 Kotlin: X3DH, and the account KDF beneath it
The Android engine had primitives and no protocol: no handshake, no ratchet, no
session. It could verify a safety number and could not start a conversation.
X3DH is the first half of fixing that.

The four agreements are mixed in the order the spec fixes, and DH4 is omitted
rather than zero-filled when the one-time prekey pool is drained. Both are
choices that produce 32 plausible bytes when wrong — the failure lands at the
far end as a message that will not decrypt, pointing nowhere near here, so the
tests check that the two sides AGREE rather than that either looks right.

Keys are JCA objects rather than raw bytes throughout, because Conscrypt will
not build an XDH key from raw bytes and wants a DER spec. Passing raw bytes
would work on this JVM and throw on the first tap on a phone, which this engine
has already been bitten by once.

Also here: AccountKdf, so Android can derive an account key and an address.
Checked against the vectors pinned from the enclave client — including the
non-ASCII and empty-passphrase cases, where a CharArray API and a UTF-8 wire
format quietly disagree.

⚠️ Still missing before an Android app can send a message: the Double Ratchet,
a session layer, a wire client, and the app itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:47:12 -04:00

115 lines
4.3 KiB
Kotlin

package io.flamenet.e2e
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* X3DH, both directions.
*
* The property that matters is agreement: Alice computes a secret from her
* private keys and Bob's public bundle, Bob computes one from his private keys
* and what Alice sent, and the two must be identical. A wrong DH order, a
* swapped argument, or a zero-filled DH4 all still produce 32 plausible bytes —
* they just produce *different* 32 bytes on each side, which is why this checks
* equality rather than shape.
*/
class X3DHTest {
private fun raw(kp: java.security.KeyPair) = X25519.rawFromPublicKey(kp.public)
@Test
fun `both sides derive the same secret, with a one-time prekey`() {
val aliceIdentity = X25519.generate()
val bobIdentity = X25519.generate()
val bobSigned = X25519.generate()
val bobOneTime = X25519.generate()
val alice = X3DH.initiate(
identityPrivateDH = aliceIdentity.private,
bundle = X3DH.Bundle(
identityKeyDH = raw(bobIdentity),
signedPreKey = raw(bobSigned),
oneTimePreKey = raw(bobOneTime),
),
)
val bob = X3DH.respond(
identityPrivateDH = bobIdentity.private,
signedPreKeyPrivate = bobSigned.private,
oneTimePrivate = bobOneTime.private,
initiatorIdentityDH = raw(aliceIdentity),
initiatorEphemeral = alice.ephemeralPublic,
)
assertContentEquals(alice.sharedSecret, bob, "the two sides disagree")
assertTrue(alice.sharedSecret.size == 32)
}
@Test
fun `both sides agree when the one-time prekey pool is drained`() {
// A degraded but valid state: initial forward secrecy is lost, and the
// session must still work. Omitting DH4 on one side while the other
// zero-fills it is the classic way to break exactly this case.
val aliceIdentity = X25519.generate()
val bobIdentity = X25519.generate()
val bobSigned = X25519.generate()
val alice = X3DH.initiate(
identityPrivateDH = aliceIdentity.private,
bundle = X3DH.Bundle(raw(bobIdentity), raw(bobSigned), null),
)
val bob = X3DH.respond(
identityPrivateDH = bobIdentity.private,
signedPreKeyPrivate = bobSigned.private,
oneTimePrivate = null,
initiatorIdentityDH = raw(aliceIdentity),
initiatorEphemeral = alice.ephemeralPublic,
)
assertContentEquals(alice.sharedSecret, bob)
}
@Test
fun `using a one-time prekey changes the result`() {
// Otherwise DH4 is contributing nothing and its absence would be
// undetectable — which would make a drained pool silently equivalent to
// a healthy one.
val aliceIdentity = X25519.generate()
val bobIdentity = X25519.generate()
val bobSigned = X25519.generate()
val bobOneTime = X25519.generate()
val ephemeral = X25519.generate()
val withOpk = X3DH.initiate(
aliceIdentity.private,
X3DH.Bundle(raw(bobIdentity), raw(bobSigned), raw(bobOneTime)),
ephemeral,
)
val withoutOpk = X3DH.initiate(
aliceIdentity.private,
X3DH.Bundle(raw(bobIdentity), raw(bobSigned), null),
ephemeral,
)
assertFalse(withOpk.sharedSecret.contentEquals(withoutOpk.sharedSecret))
}
@Test
fun `a different responder derives a different secret`() {
val aliceIdentity = X25519.generate()
val bobIdentity = X25519.generate()
val bobSigned = X25519.generate()
val malloryIdentity = X25519.generate()
val mallorySigned = X25519.generate()
val toBob = X3DH.initiate(
aliceIdentity.private, X3DH.Bundle(raw(bobIdentity), raw(bobSigned), null))
val malloryTries = X3DH.respond(
identityPrivateDH = malloryIdentity.private,
signedPreKeyPrivate = mallorySigned.private,
oneTimePrivate = null,
initiatorIdentityDH = raw(aliceIdentity),
initiatorEphemeral = toBob.ephemeralPublic,
)
assertFalse(toBob.sharedSecret.contentEquals(malloryTries))
}
}