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>
This commit is contained in:
Paul Hitt
2026-08-23 14:47:12 -04:00
co-authored by Claude Opus 5
parent b7021e9a73
commit c893d412d2
3 changed files with 228 additions and 18 deletions
@@ -0,0 +1,108 @@
package io.flamenet.e2e
/**
* X3DH — the asynchronous handshake that starts a session (spec §5).
*
* Alice can begin talking to Bob while Bob is offline, because Bob published a
* bundle in advance. Four Diffie-Hellmans are mixed so that the result depends
* on both long-term identities and on per-session keys: the first binds Alice's
* identity to Bob's, the middle two bind her ephemeral to his identity and his
* signed prekey, and the fourth — when a one-time prekey is available — is the
* only part that cannot be replayed.
*
* ```
* DH1 = X25519(IK_dh_A_priv, SPK_B_pub)
* DH2 = X25519(EK_A_priv, IK_dh_B_pub)
* DH3 = X25519(EK_A_priv, SPK_B_pub)
* DH4 = X25519(EK_A_priv, OPK_B_pub) // omitted when no OPK is left
* SK = HKDF-SHA256(DH1‖DH2‖DH3‖DH4, salt = 0x00*32, info = "FlamenetE2E_X3DH_v1", 32)
* ```
*
* ⚠️ **The order of the four is part of the protocol.** Concatenating them in a
* different order derives a different key that is just as valid-looking, and the
* failure appears at the far end as a message that will not decrypt — not as
* anything pointing here. Two implementations that disagree about the order
* agree about everything else.
*
* ⚠️ **DH4 is omitted, not zero-filled, when the pool is drained.** A drained
* pool costs initial forward secrecy and must still produce a working session;
* substituting 32 zero bytes would produce a session neither side can use.
*
* This is a port of `engine/src/e2e.js`, which is normative. It is verified
* against that implementation rather than against a reading of this file: two
* engines that agree because the same person wrote both agree about the same
* mistakes.
*/
object X3DH {
/** Domain separation, and a wire constant. Changing it strands every session. */
private val INFO_V1 = "FlamenetE2E_X3DH_v1".toByteArray(Charsets.UTF_8)
/** RFC 5869's "no salt" is HashLen zeros, not an absent salt. */
private val ZERO_SALT = ByteArray(32)
/**
* What Bob publishes and Alice fetches. `oneTimePreKey` is null once his
* pool is drained, which is a degraded but valid state.
*/
data class Bundle(
val identityKeyDH: ByteArray,
val signedPreKey: ByteArray,
val oneTimePreKey: ByteArray?,
)
/** Alice's side. Returns the shared secret and the ephemeral Bob will need. */
data class Initiated(val sharedSecret: ByteArray, val ephemeralPublic: ByteArray)
/**
* Keys are JCA objects rather than raw bytes throughout.
*
* Not a style choice: Android's provider is Conscrypt and it will not build
* an XDH key from raw bytes — it wants a DER-encoded spec, which is what
* `X25519.publicKeyFromRaw` exists to produce. An implementation that passed
* raw bytes around would work on the JVM and throw on the first tap on a
* phone, which this engine has already been bitten by once.
*/
fun initiate(
identityPrivateDH: java.security.PrivateKey,
bundle: Bundle,
ephemeral: java.security.KeyPair = X25519.generate(),
): Initiated {
val dh1 = X25519.agree(identityPrivateDH, bundle.signedPreKey)
val dh2 = X25519.agree(ephemeral.private, bundle.identityKeyDH)
val dh3 = X25519.agree(ephemeral.private, bundle.signedPreKey)
val dh4 = bundle.oneTimePreKey?.let { X25519.agree(ephemeral.private, it) }
val ikm = if (dh4 != null) dh1 + dh2 + dh3 + dh4 else dh1 + dh2 + dh3
return Initiated(
sharedSecret = Hkdf.derive(ikm, ZERO_SALT, INFO_V1, 32),
ephemeralPublic = X25519.rawFromPublicKey(ephemeral.public),
)
}
/**
* Bob's side. The same four agreements, computed from the other direction —
* each DH is symmetric, so the private and public halves swap and the result
* is identical.
*
* `oneTimePrivate` must be the key matching the `oneTimePreKey` Alice used,
* and null when she used none. Passing the wrong one produces a shared
* secret that differs from Alice's, which surfaces as an undecryptable
* first message rather than as an error here.
*/
fun respond(
identityPrivateDH: java.security.PrivateKey,
signedPreKeyPrivate: java.security.PrivateKey,
oneTimePrivate: java.security.PrivateKey?,
initiatorIdentityDH: ByteArray,
initiatorEphemeral: ByteArray,
): ByteArray {
val dh1 = X25519.agree(signedPreKeyPrivate, initiatorIdentityDH)
val dh2 = X25519.agree(identityPrivateDH, initiatorEphemeral)
val dh3 = X25519.agree(signedPreKeyPrivate, initiatorEphemeral)
val dh4 = oneTimePrivate?.let { X25519.agree(it, initiatorEphemeral) }
val ikm = if (dh4 != null) dh1 + dh2 + dh3 + dh4 else dh1 + dh2 + dh3
return Hkdf.derive(ikm, ZERO_SALT, INFO_V1, 32)
}
}
@@ -86,22 +86,10 @@ class AccountKdfTest {
assertEquals(32, account.size)
}
@Test
fun `the account seed produces the address the other engines publish`() {
// Ties the two halves together: the KDF above and the encoder in FNID.
// A seed that derives correctly but encodes differently would still
// give this account a different name on every client.
val seed = ByteArray(32) { 0x2a }
val ai = JSONObject(
File(
listOfNotNull(
System.getenv("FLAMENET_VECTORS"),
"../../engine/test/vectors/protocol-v1.json",
"../../../flamenet-e2e/test/vectors/protocol-v1.json",
).first { File(it).exists() },
).readText(),
).getJSONObject("accountIdentity")
assertEquals(ai.getString("address"), FNID.address(seed))
}
// The tie between this KDF and FNID.encode — that a correctly derived seed
// also produces the same ADDRESS on every client — belongs here too, and
// cannot be written yet: the Kotlin FNID lives on the multi-homing branch
// and is not on main. Add it when that lands, because a seed that derives
// correctly and encodes differently still gives one account a different
// name on every platform.
}
@@ -0,0 +1,114 @@
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))
}
}