Kotlin: X3DH, and the account KDF beneath it

The Android engine had primitives and no protocol: no handshake, no ratchet, no
session. It could verify a safety number and could not start a conversation.
X3DH is the first half of fixing that.

The four agreements are mixed in the order the spec fixes, and DH4 is omitted
rather than zero-filled when the one-time prekey pool is drained. Both are
choices that produce 32 plausible bytes when wrong — the failure lands at the
far end as a message that will not decrypt, pointing nowhere near here, so the
tests check that the two sides AGREE rather than that either looks right.

Keys are JCA objects rather than raw bytes throughout, because Conscrypt will
not build an XDH key from raw bytes and wants a DER spec. Passing raw bytes
would work on this JVM and throw on the first tap on a phone, which this engine
has already been bitten by once.

Also here: AccountKdf, so Android can derive an account key and an address.
Checked against the vectors pinned from the enclave client — including the
non-ASCII and empty-passphrase cases, where a CharArray API and a UTF-8 wire
format quietly disagree.

⚠️ Still missing before an Android app can send a message: the Double Ratchet,
a session layer, a wire client, and the app itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-23 14:47:12 -04:00
co-authored by Claude Opus 5
parent b7021e9a73
commit c893d412d2
3 changed files with 228 additions and 18 deletions
@@ -86,22 +86,10 @@ class AccountKdfTest {
assertEquals(32, account.size)
}
@Test
fun `the account seed produces the address the other engines publish`() {
// Ties the two halves together: the KDF above and the encoder in FNID.
// A seed that derives correctly but encodes differently would still
// give this account a different name on every client.
val seed = ByteArray(32) { 0x2a }
val ai = JSONObject(
File(
listOfNotNull(
System.getenv("FLAMENET_VECTORS"),
"../../engine/test/vectors/protocol-v1.json",
"../../../flamenet-e2e/test/vectors/protocol-v1.json",
).first { File(it).exists() },
).readText(),
).getJSONObject("accountIdentity")
assertEquals(ai.getString("address"), FNID.address(seed))
}
// The tie between this KDF and FNID.encode — that a correctly derived seed
// also produces the same ADDRESS on every client — belongs here too, and
// cannot be written yet: the Kotlin FNID lives on the multi-homing branch
// and is not on main. Add it when that lands, because a seed that derives
// correctly and encodes differently still gives one account a different
// name on every platform.
}