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
}
}
@@ -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))
}
}
+25
View File
@@ -0,0 +1,25 @@
// The Kotlin half of the cross-engine interop harness.
//
// Deliberately a separate module rather than a test source set in `:engine`:
// it depends on a JSON library and on the `application` plugin, and neither
// belongs anywhere near the shipped protocol code. The engine's own
// dependency-free build is worth protecting.
plugins {
kotlin("jvm")
application
}
repositories { mavenCentral() }
dependencies {
implementation(project(":engine"))
implementation("org.json:json:20240303")
}
application {
mainClass.set("io.flamenet.interop.MainKt")
}
// No `jvmTarget` pin here, unlike `:engine`. This never runs on Android, so
// bytecode level 17 buys nothing — and pinning it while `compileJava` follows
// the local JDK is how the two tasks end up disagreeing and failing the build.
@@ -0,0 +1,265 @@
package io.flamenet.interop
import io.flamenet.e2e.AccountKdf
import io.flamenet.e2e.Aead
import io.flamenet.e2e.DoubleRatchet
import io.flamenet.e2e.Ed25519
import io.flamenet.e2e.Hkdf
import io.flamenet.e2e.KeyLog
import io.flamenet.e2e.SafetyNumber
import io.flamenet.e2e.X25519
import io.flamenet.e2e.X3DH
import org.json.JSONObject
import java.security.KeyPair
/**
* The Kotlin side of the cross-engine interop harness (spec §12.10.3).
*
* Reads one JSON request per line on stdin and writes one JSON response per
* line on stdout. `engine/test/interop-kotlin.mjs` drives it against the web
* engine, which is normative.
*
* ⚠️ **This must call the engine, never reimplement it.** The moment this file
* contains its own HKDF or its own header encoding, the harness starts proving
* that this file agrees with the web engine — which is worthless, because this
* file is not what runs on a phone.
*
* Long-lived rather than one process per request: the alternative is
* serialising ratchet state across the boundary, and a state serialiser written
* for a test is a second implementation of the ratchet's invariants. Sessions
* therefore live here, behind an id.
*
* Errors go back as `{"error": "..."}` on stdout, not as a crash: a driver that
* dies takes every later request with it, and the harness then reports one
* failure where there were twenty.
*/
private val devices = HashMap<String, Device>()
private val sessions = HashMap<String, Session>()
private var counter = 0
private class Device(
val deviceId: String,
val ikDH: KeyPair,
val ikSig: KeyPair,
val signedPreKeyId: Int,
val signedPreKey: KeyPair,
val oneTimePreKeyId: Int,
val oneTimePreKey: KeyPair,
)
private class Session(val ratchet: DoubleRatchet, val ad: ByteArray)
fun main() {
val out = System.out.bufferedWriter()
generateSequence(::readLine).forEach { line ->
if (line.isBlank()) return@forEach
val reply = try {
handle(JSONObject(line))
} catch (e: Throwable) {
JSONObject().put("error", "${e::class.simpleName}: ${e.message}")
}
out.write(reply.toString())
out.newLine()
out.flush()
}
}
private fun next(prefix: String): String { counter += 1; return "$prefix$counter" }
/**
* Base64 here rather than through the engine's own `Bytes`, which is
* deliberately `internal` — the engine's public surface is the protocol, not
* its helpers, and widening it so a test tool can reach in would be the tail
* wagging the dog. Base64 is a transport encoding with one right answer, so a
* second call site cannot drift; the protocol-bearing encodings (the ratchet
* header, the canonical log leaf) still come from the engine.
*/
private fun b64(data: ByteArray): String = java.util.Base64.getEncoder().encodeToString(data)
private fun unb64(text: String): ByteArray = java.util.Base64.getDecoder().decode(text)
private fun JSONObject.bytes(key: String): ByteArray = unb64(getString(key))
private fun handle(req: JSONObject): JSONObject = when (req.getString("cmd")) {
"device" -> newDevice()
"initiate" -> initiate(req)
"respond" -> respond(req)
"encrypt" -> encrypt(req)
"decrypt" -> decrypt(req)
"safety" -> JSONObject().put(
"number", SafetyNumber.compute(req.bytes("local"), req.bytes("remote")))
"account_kdf" -> accountKdf(req)
"log_leaf" -> logLeaf(req)
"hkdf" -> JSONObject().put("okm", b64(Hkdf.derive(
req.bytes("ikm"), req.bytes("salt"), req.bytes("info"), req.getInt("length"))))
"aead_open" -> JSONObject().put("plaintext", String(Aead.open(
req.bytes("key"), req.bytes("box"), req.bytes("aad")), Charsets.UTF_8))
"verify" -> JSONObject().put("ok", Ed25519.verify(
req.bytes("public_key"), req.bytes("signature"), req.bytes("message")))
else -> JSONObject().put("error", "unknown cmd ${req.getString("cmd")}")
}
/**
* A device and the bundle a relay would serve for it.
*
* `proto` is 1 and no PQ block is published, because this engine has no ML-KEM
* yet. That is a real limitation stated as data rather than hidden: the web
* engine's `negotiate()` sees a v1 peer and says so, and the harness asserts
* exactly that — so the day Kotlin gains PQ, this bundle changing is what makes
* the assertion fail and get updated.
*/
private fun newDevice(): JSONObject {
val id = next("kotlin-device-")
val device = Device(
deviceId = id,
ikDH = X25519.generate(),
ikSig = Ed25519.generate(),
signedPreKeyId = 1,
signedPreKey = X25519.generate(),
oneTimePreKeyId = 7,
oneTimePreKey = X25519.generate(),
)
devices[id] = device
val spkRaw = X25519.rawFromPublicKey(device.signedPreKey.public)
val bundle = JSONObject()
.put("device_id", id)
.put("ik_dh", b64(X25519.rawFromPublicKey(device.ikDH.public)))
.put("ik_sig", b64(Ed25519.rawFromPublicKey(device.ikSig.public)))
.put("spk_id", device.signedPreKeyId)
.put("spk", b64(spkRaw))
.put("spk_sig", b64(Ed25519.sign(device.ikSig.private, spkRaw)))
.put("opk", JSONObject()
.put("id", device.oneTimePreKeyId)
.put("pub", b64(X25519.rawFromPublicKey(device.oneTimePreKey.public))))
.put("proto", 1)
return JSONObject().put("device", id).put("bundle", bundle)
}
/** Kotlin is Alice: X3DH against the peer's bundle, then the first message. */
private fun initiate(req: JSONObject): JSONObject {
val me = devices.getValue(req.getString("device"))
val bundle = req.getJSONObject("bundle")
// Verify the peer's signed prekey before using it. A relay that can
// substitute an unsigned prekey can read everything, and the whole point of
// the bundle carrying a signature is that the client actually checks it.
val spk = unb64(bundle.getString("spk"))
val theirIkSig = unb64(bundle.getString("ik_sig"))
if (!Ed25519.verify(theirIkSig, unb64(bundle.getString("spk_sig")), spk)) {
return JSONObject().put("error", "badSignature")
}
val opk = bundle.optJSONObject("opk")
val initiated = X3DH.initiate(
identityPrivateDH = me.ikDH.private,
bundle = X3DH.Bundle(
identityKeyDH = unb64(bundle.getString("ik_dh")),
signedPreKey = spk,
oneTimePreKey = opk?.let { unb64(it.getString("pub")) },
),
)
// v1 associated data: the two signing identities, initiator first.
val ad = Ed25519.rawFromPublicKey(me.ikSig.public) + theirIkSig
val ratchet = DoubleRatchet.initiator(initiated.sharedSecret, spk)
val id = next("kotlin-session-")
sessions[id] = Session(ratchet, ad)
val message = ratchet.encrypt(req.getString("text").toByteArray(Charsets.UTF_8), ad)
return JSONObject()
.put("session", id)
.put("ad", b64(ad))
.put("ek", b64(initiated.ephemeralPublic))
.put("opk_id", opk?.getInt("id") ?: -1)
.put("spk_id", bundle.optInt("spk_id", 0))
.put("header", headerJson(message.header))
.put("ct", b64(message.ciphertext))
}
/** Kotlin is Bob: X3DH from the other side, then open the first message. */
private fun respond(req: JSONObject): JSONObject {
val me = devices.getValue(req.getString("device"))
val usedOpk = req.optInt("opk_id", -1) == me.oneTimePreKeyId
val theirIkDH = req.bytes("ik_dh")
val theirIkSig = req.bytes("ik_sig")
val sharedSecret = X3DH.respond(
identityPrivateDH = me.ikDH.private,
signedPreKeyPrivate = me.signedPreKey.private,
oneTimePrivate = if (usedOpk) me.oneTimePreKey.private else null,
initiatorIdentityDH = theirIkDH,
initiatorEphemeral = req.bytes("ek"),
)
val ad = theirIkSig + Ed25519.rawFromPublicKey(me.ikSig.public)
val ratchet = DoubleRatchet.responder(sharedSecret, me.signedPreKey)
val id = next("kotlin-session-")
sessions[id] = Session(ratchet, ad)
val text = ratchet.decrypt(messageFrom(req), ad)
return JSONObject()
.put("session", id)
.put("ad", b64(ad))
.put("text", String(text, Charsets.UTF_8))
}
private fun encrypt(req: JSONObject): JSONObject {
val session = sessions.getValue(req.getString("session"))
val message = session.ratchet.encrypt(
req.getString("text").toByteArray(Charsets.UTF_8), session.ad)
return JSONObject()
.put("header", headerJson(message.header))
.put("ct", b64(message.ciphertext))
}
private fun decrypt(req: JSONObject): JSONObject {
val session = sessions.getValue(req.getString("session"))
val text = session.ratchet.decrypt(messageFrom(req), session.ad)
return JSONObject().put("text", String(text, Charsets.UTF_8))
}
private fun headerJson(header: DoubleRatchet.Header): JSONObject = JSONObject()
.put("dh", b64(header.dh))
.put("pn", header.pn)
.put("n", header.n)
private fun messageFrom(req: JSONObject): DoubleRatchet.Message {
val header = req.getJSONObject("header")
return DoubleRatchet.Message(
header = DoubleRatchet.Header(
dh = unb64(header.getString("dh")),
pn = header.getInt("pn"),
n = header.getInt("n"),
),
ciphertext = req.bytes("ct"),
)
}
private fun accountKdf(req: JSONObject): JSONObject {
val passphrase = req.getString("passphrase")
val salt = req.bytes("salt")
val seed = AccountKdf.accountKeySeed(passphrase, salt)
return JSONObject()
.put("auth", b64(AccountKdf.authSecret(passphrase, salt)))
.put("identity", b64(AccountKdf.identityKey(passphrase, salt)))
.put("account_seed", b64(seed))
// The address a user would actually see, so a divergence anywhere in
// the chain — PBKDF2, HKDF, the context string, Ed25519 keygen —
// surfaces as the wrong address rather than as the wrong bytes.
.put("account_public", b64(Ed25519.rawFromPublicKey(Ed25519.fromSeed(seed).public)))
}
private fun logLeaf(req: JSONObject): JSONObject {
val leaf = KeyLog.canonicalLeaf(
kind = req.getString("kind"),
subject = req.getString("subject"),
deviceId = req.getString("device_id"),
ikSigB64 = req.getString("ik_sig"),
ikDhB64 = req.getString("ik_dh"),
)
return JSONObject()
.put("leaf", String(leaf, Charsets.UTF_8))
.put("hash", b64(KeyLog.leafHash(leaf)))
}
+3
View File
@@ -7,3 +7,6 @@
rootProject.name = "flamenet-android"
include(":engine")
// The cross-engine interop driver. Not shipped; see `engine/test/interop-kotlin.mjs`.
include(":interop")