Kotlin: Ed25519, and cross-engine interop against the web engine

The seventeen Kotlin tests that landed with X3DH and the ratchet prove the
engine agrees with itself. That is not the property anyone needs. Two engines
written by the same person agree about the same misreadings of the spec, and
every one of those shows up months later as "the message will not decrypt" on
someone else's phone. So this wires the Kotlin engine up against the web
engine, which is normative, in both directions.

23 assertions, all passing: v1 X3DH both ways, identical associated data,
alternating ratchet turns past the handshake, out-of-order and permanently
lost messages, a drained one-time prekey pool, tamper rejection, safety
numbers, and the canonical transparency-log leaf.

Three things worth recording.

Ed25519 was missing, and the engine could derive an account address but never
sign a claim for one. The JCA offers no route from a private EdDSA key to its
public half, so the RFC 8032 keygen is done here -- and verified against the
RFC 8032 test vectors, not against a round-trip through our own code. A
round-trip proves only that the file agrees with itself, and every wrong clamp,
endianness and sign convention agrees with itself perfectly. The scalar
multiplication uses BigInteger and is therefore not constant-time; it runs once
at sign-in, in the user's own process, and signing deliberately stays with the
platform provider.

The harness reports a build failure as a failure, never a skip. The Swift
harness beside it once printed "skipped" -- which reads as green -- while
silently building a different checkout. Only a missing JDK skips here, and the
resolved path is printed either way.

Each section is wrapped so a divergence is a named failure rather than a crash.
That was not theoretical: breaking the Kotlin message-key derivation on purpose
crashed the process with a stack trace and skipped every later section. It now
reports four failing sections and finishes the run. The negative control is how
this was checked at all -- a suite that passes on its first run and has never
been seen to fail is not evidence of anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-23 15:04:35 -04:00
co-authored by Claude Opus 5
parent e0589eff14
commit fc93df24e6
5 changed files with 615 additions and 0 deletions
@@ -0,0 +1,112 @@
package io.flamenet.e2e
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* ⚠️ The seed→public derivation is checked against the **RFC 8032 §7.1**
* vectors, not against a round-trip through our own code. A round-trip proves
* only that this file agrees with itself, and every wrong clamp, endianness and
* sign convention agrees with itself perfectly. These vectors are what say the
* address a user gets here is the address they get on iOS and on the web.
*/
class Ed25519Test {
private fun hex(s: String) = ByteArray(s.length / 2) {
s.substring(it * 2, it * 2 + 2).toInt(16).toByte()
}
/** seed, expected public key, message, expected signature. */
private val vectors = listOf(
Triple(
"9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",
"d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
"" to "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b",
),
Triple(
"4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb",
"3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c",
"72" to "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00",
),
Triple(
"c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7",
"fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025",
"af82" to "6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a",
),
)
@Test
fun `a seed derives the public key RFC 8032 says it must`() {
for ((seed, expected, _) in vectors) {
val pair = Ed25519.fromSeed(hex(seed))
assertContentEquals(hex(expected), Ed25519.rawFromPublicKey(pair.public),
"seed $seed derived the wrong public key")
}
}
@Test
fun `signatures match the published vectors byte for byte`() {
for ((seed, _, msgSig) in vectors) {
val (message, signature) = msgSig
val pair = Ed25519.fromSeed(hex(seed))
assertContentEquals(hex(signature), Ed25519.sign(pair.private, hex(message)))
}
}
@Test
fun `the published signatures verify`() {
for ((_, publicKey, msgSig) in vectors) {
val (message, signature) = msgSig
assertTrue(Ed25519.verify(hex(publicKey), hex(signature), hex(message)))
}
}
@Test
fun `a signature does not verify under a different key`() {
val (_, otherKey, _) = vectors[1]
val (_, _, msgSig) = vectors[0]
assertFalse(Ed25519.verify(hex(otherKey), hex(msgSig.second), hex(msgSig.first)))
}
@Test
fun `a flipped bit anywhere is refused`() {
val (seed, publicKey, _) = vectors[2]
val pair = Ed25519.fromSeed(hex(seed))
val message = "prekey bytes".toByteArray()
val signature = Ed25519.sign(pair.private, message)
for (i in signature.indices step 7) {
val bent = signature.copyOf()
bent[i] = (bent[i].toInt() xor 0x01).toByte()
assertFalse(Ed25519.verify(hex(publicKey), bent, message), "byte $i went undetected")
}
val bentMessage = message.copyOf().also { it[0] = (it[0].toInt() xor 1).toByte() }
assertFalse(Ed25519.verify(hex(publicKey), signature, bentMessage))
}
/**
* Garbage from the wire must be a `false`, not an exception. A caller that
* has to catch around every verification eventually catches one badly, and
* a swallowed throw reads as a pass.
*/
@Test
fun `malformed input verifies as false rather than throwing`() {
val (_, publicKey, msgSig) = vectors[0]
assertFalse(Ed25519.verify(hex(publicKey), ByteArray(0), ByteArray(0)))
assertFalse(Ed25519.verify(hex(publicKey), ByteArray(64), ByteArray(0)))
assertFalse(Ed25519.verify(ByteArray(31), hex(msgSig.second), ByteArray(0)))
assertFalse(Ed25519.verify(ByteArray(32), hex(msgSig.second), ByteArray(0)))
}
@Test
fun `a generated keypair round-trips through its raw encoding`() {
val pair = Ed25519.generate()
val raw = Ed25519.rawFromPublicKey(pair.public)
assertEquals(32, raw.size)
val message = "round trip".toByteArray()
assertTrue(Ed25519.verify(raw, Ed25519.sign(pair.private, message), message))
}
}