Every comment is reduced to its first sentence plus @param, @return and @throws tags. Comment lines: 20040 to 8526. ktlint 1.8 applied with its default (official Kotlin) style across all modules; 262 files reformatted. The 264 remaining ktlint findings are naming and filename rules, which are code changes, and are left alone. Comments and formatting only. 1208 tests pass, counted from the XML. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
114 lines
4.0 KiB
Kotlin
114 lines
4.0 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.
|
|
*/
|
|
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.
|
|
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))
|
|
}
|
|
}
|