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:
co-authored by
Claude Opus 5
parent
b7021e9a73
commit
c893d412d2
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user