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