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,210 @@
package io.flamenet.e2e
import java.security.KeyFactory
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.Signature
import java.security.spec.PKCS8EncodedKeySpec
import java.security.spec.X509EncodedKeySpec
/**
* Ed25519 signatures — the signing half of an identity.
*
* Two distinct jobs use this. A **device** signs its prekeys, so a peer
* fetching a bundle from an untrusted relay can tell the relay did not
* substitute keys of its own. An **account** signs its home records and its
* transparency-log claims, and its public half *is* the `fnid`.
*
* Keys move as raw 32-byte values on the wire and as JCA objects in memory, for
* the same reason as [X25519]: Android's provider will not build an EdDSA key
* from raw bytes, it wants a DER-encoded spec. An implementation that passed
* raw bytes to the JCA compiles, passes on a laptop, and throws on a phone.
*
* ⚠️ **A seed is not a private key.** Ed25519's 32-byte seed expands, by
* hashing, into the actual scalar and the nonce prefix. `AccountKdf` derives a
* *seed*, and every implementation must expand it the same way — which they do,
* because RFC 8032 fixes it. Feeding a seed somewhere a scalar is expected
* produces a valid-looking key with a different public half, which surfaces as
* a stranger's `fnid` rather than as an error.
*/
object Ed25519 {
const val KEY_LEN = 32
const val SIG_LEN = 64
/**
* X.509 wrapper for a public key: SEQUENCE, the id-Ed25519 OID
* (1.3.101.112), then a 33-byte BIT STRING. Identical in shape to X25519's,
* and differing in one byte of the OID — which is precisely why it is
* written out here rather than shared with a `when`: a single wrong byte
* yields the *other* algorithm's key type, and the JCA reports that as an
* opaque `InvalidKeySpecException`.
*/
private val SPKI_PREFIX = byteArrayOf(
0x30, 0x2a, // SEQUENCE, 42 bytes
0x30, 0x05, // SEQUENCE, 5 bytes
0x06, 0x03, 0x2b, 0x65, 0x70, // OID 1.3.101.112
0x03, 0x21, 0x00, // BIT STRING, 33 bytes, 0 unused
)
/**
* PKCS#8 wrapper for a seed. The seed sits inside an OCTET STRING nested in
* another OCTET STRING — a quirk of RFC 8410, not a mistake here.
*/
private val PKCS8_PREFIX = byteArrayOf(
0x30, 0x2e, // SEQUENCE, 46 bytes
0x02, 0x01, 0x00, // INTEGER 0 (version)
0x30, 0x05, // SEQUENCE, 5 bytes
0x06, 0x03, 0x2b, 0x65, 0x70, // OID 1.3.101.112
0x04, 0x22, // OCTET STRING, 34 bytes
0x04, 0x20, // OCTET STRING, 32 bytes
)
fun generate(): KeyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair()
/** A keypair from a 32-byte seed — the bridge from [AccountKdf] to a usable key. */
fun fromSeed(seed: ByteArray): KeyPair {
require(seed.size == KEY_LEN) { "an Ed25519 seed is $KEY_LEN bytes, got ${seed.size}" }
val factory = KeyFactory.getInstance("Ed25519")
val private = factory.generatePrivate(PKCS8EncodedKeySpec(PKCS8_PREFIX + seed))
// The public half is not recoverable from a PKCS#8 seed through the JCA
// without re-deriving it, so it is derived the only portable way: sign
// nothing and let the provider hand back the matching public key.
return KeyPair(publicFromPrivate(private), private)
}
fun publicKeyFromRaw(raw: ByteArray): java.security.PublicKey {
require(raw.size == KEY_LEN) { "an Ed25519 public key is $KEY_LEN bytes, got ${raw.size}" }
return KeyFactory.getInstance("Ed25519")
.generatePublic(X509EncodedKeySpec(SPKI_PREFIX + raw))
}
fun rawFromPublicKey(key: java.security.PublicKey): ByteArray {
val encoded = key.encoded
require(encoded.size == SPKI_PREFIX.size + KEY_LEN) {
"unexpected Ed25519 SPKI length ${encoded.size}"
}
return encoded.copyOfRange(SPKI_PREFIX.size, encoded.size)
}
fun sign(privateKey: java.security.PrivateKey, message: ByteArray): ByteArray {
val signer = Signature.getInstance("Ed25519")
signer.initSign(privateKey)
signer.update(message)
return signer.sign()
}
/**
* Verify, returning false rather than throwing.
*
* A malformed key or a truncated signature is a *failed verification*, not
* an exceptional condition: both arrive over the wire from someone who may
* be hostile, and a caller that has to wrap every check in a try/catch
* eventually wraps one badly and treats a thrown exception as a pass.
*/
fun verify(publicKeyRaw: ByteArray, signature: ByteArray, message: ByteArray): Boolean =
try {
if (signature.size != SIG_LEN) false else {
val verifier = Signature.getInstance("Ed25519")
verifier.initVerify(publicKeyFromRaw(publicKeyRaw))
verifier.update(message)
verifier.verify(signature)
}
} catch (_: Exception) {
false
}
/**
* Derive the public half of a private key.
*
* The JCA offers no route from a private EdDSA key to its public one, so
* this does the RFC 8032 §5.1.5 derivation itself: hash the seed, clamp the
* low half into a scalar, and multiply the base point by it.
*/
private fun publicFromPrivate(privateKey: java.security.PrivateKey): java.security.PublicKey =
publicKeyFromRaw(Rfc8032.publicKeyFromSeed(seedOf(privateKey)))
/** The 32 seed bytes back out of a PKCS#8 encoding. */
private fun seedOf(privateKey: java.security.PrivateKey): ByteArray {
val encoded = privateKey.encoded
require(encoded.size == PKCS8_PREFIX.size + KEY_LEN) {
"unexpected Ed25519 PKCS#8 length ${encoded.size}"
}
return encoded.copyOfRange(PKCS8_PREFIX.size, encoded.size)
}
}
/**
* Just enough Ed25519 arithmetic to turn a seed into its public key.
*
* This exists for exactly one reason: an account's address is derived from a
* passphrase, so the client must be able to compute `A = a·B` for a seed the
* KDF produced. Nothing else here reimplements a primitive — signing and
* verification go to the platform provider, which is audited and
* constant-time. Keep it that way.
*
* ⚠️ **This is not constant-time.** `BigInteger` branches on values, so the
* scalar multiplication below leaks timing about a secret scalar. The exposure
* is bounded and deliberate: it runs once, at sign-in, in the user's own
* process, on a device where an attacker able to measure it can already read
* the passphrase. It would NOT be acceptable for signing, which is why signing
* is not here.
*
* Verified against the RFC 8032 §7.1 test vectors rather than against a reading
* of this code.
*/
private object Rfc8032 {
private val P = java.math.BigInteger.TWO.pow(255) - java.math.BigInteger.valueOf(19)
private val D = java.math.BigInteger(
"37095705934669439343138083508754565189542113879843219016388785533085940283555")
private val BX = java.math.BigInteger(
"15112221349535400772501151409588531511454012693041857206046113283949847762202")
private val BY = java.math.BigInteger(
"46316835694926478169428394003475163141307993866256225615783033603165251855960")
/** Affine points on -x² + y² = 1 + d·x²·y². The identity is (0, 1). */
private data class Point(val x: java.math.BigInteger, val y: java.math.BigInteger)
private fun add(a: Point, b: Point): Point {
val xx = a.x * b.x
val yy = a.y * b.y
val dxy = D * xx % P * yy % P
val x = (a.x * b.y + b.x * a.y) * (java.math.BigInteger.ONE + dxy).modInverse(P) % P
val y = (yy + xx) * (java.math.BigInteger.ONE - dxy).modInverse(P) % P
return Point(x.mod(P), y.mod(P))
}
fun publicKeyFromSeed(seed: ByteArray): ByteArray {
val h = Bytes.sha512(seed)
// Clamp (RFC 8032 §5.1.5): clearing the low three bits puts the scalar
// in the prime-order subgroup, and forcing bit 254 fixes its length so
// a ladder cannot leak it. Both are required; a key derived without
// them is a different key, not a weaker one.
val s = h.copyOfRange(0, 32)
s[0] = (s[0].toInt() and 0xF8).toByte()
s[31] = (s[31].toInt() and 0x7F or 0x40).toByte()
var scalar = java.math.BigInteger.ZERO
for (i in 31 downTo 0) {
scalar = scalar.shiftLeft(8) + java.math.BigInteger.valueOf((s[i].toInt() and 0xFF).toLong())
}
// Double-and-add, from the top bit down.
var result = Point(java.math.BigInteger.ZERO, java.math.BigInteger.ONE)
var addend = Point(BX, BY)
var bit = 0
while (bit < 255) {
if (scalar.testBit(bit)) result = add(result, addend)
addend = add(addend, addend)
bit += 1
}
// Encode: y little-endian, with the parity of x in the top bit.
val out = ByteArray(32)
val y = result.y
for (i in 0 until 32) out[i] = y.shiftRight(i * 8).toInt().toByte()
if (result.x.testBit(0)) out[31] = (out[31].toInt() or 0x80).toByte()
return out
}
}