diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Ed25519.kt b/engine/src/main/kotlin/io/flamenet/e2e/Ed25519.kt new file mode 100644 index 0000000..10b6958 --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/Ed25519.kt @@ -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 + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/Ed25519Test.kt b/engine/src/test/kotlin/io/flamenet/e2e/Ed25519Test.kt new file mode 100644 index 0000000..2beb790 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/Ed25519Test.kt @@ -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)) + } +} diff --git a/interop/build.gradle.kts b/interop/build.gradle.kts new file mode 100644 index 0000000..7f5d5c0 --- /dev/null +++ b/interop/build.gradle.kts @@ -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. diff --git a/interop/src/main/kotlin/io/flamenet/interop/Main.kt b/interop/src/main/kotlin/io/flamenet/interop/Main.kt new file mode 100644 index 0000000..326f234 --- /dev/null +++ b/interop/src/main/kotlin/io/flamenet/interop/Main.kt @@ -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() +private val sessions = HashMap() +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))) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index abba4e6..8917317 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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")