An app does not use X3DH and a ratchet, it uses "send this string to that person". This is that layer: device identities with prekeys and rotation, envelopes as the relay actually carries them, and sessions that survive being put away and taken out again. 11 unit tests, and the interop harness grows from 23 assertions to 37 -- the new ones at the envelope level, which is where this protocol's one shipped bug lived. A client and a relay disagreed about a field name and nothing caught it until a live round trip; hand-assembled arguments to X3DH would not have. Kotlin now builds a whole envelope, the web engine's own session layer parses it, and both directions agree on the associated data. Three decisions worth stating. Retired signed prekeys are kept for longer than the relay keeps undelivered mail. A sender fetches a bundle, encrypts against that prekey id, and the envelope can then sit in a queue for the relay's full retention window. Discarding the private half at rotation turns every one of those into an AEAD failure -- which the receiving client cannot distinguish from an attack. A prekey retired past retention is refused outright rather than guessed at, for the same reason: guessing derives a garbage secret and reports it as tampering. The handshake block rides every message until one is answered, not until one is sent. Any message may be the first to arrive. Sessions archive with their skipped message keys, and the harness proves it against the peer rather than against our own bytes. Those keys are the messages already in flight; an archive that drops them loses exactly the mail that was in the air when the app was killed. The failure this whole path exists to prevent is a client that loses session state, re-registers a device, and shows every contact "security keys changed" -- the warning that is supposed to mean an attack. `org.json` is compileOnly: it is in the Android framework, so it costs nothing on a phone and bundling a copy would shadow the platform's. Safe only because the two encodings that must match byte-for-byte -- the ratchet header and the canonical log leaf -- are built by hand; Android's implementation preserves key order and the reference one does not. Also adds PARITY.md: what the iOS app does, what Android does, what is left. It is the definition of done, and a row moves only when something tests it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
45 lines
1.5 KiB
Kotlin
45 lines
1.5 KiB
Kotlin
plugins {
|
|
kotlin("jvm")
|
|
}
|
|
|
|
repositories { mavenCentral() }
|
|
|
|
dependencies {
|
|
testImplementation(kotlin("test"))
|
|
// `org.json` is part of the Android framework, so on a phone this costs
|
|
// zero shipped bytes and is `compileOnly` for that reason -- bundling a
|
|
// second copy would shadow the platform's and is a classic duplicate-class
|
|
// build failure. On the JVM (tests, the interop driver) it has to be a real
|
|
// dependency.
|
|
//
|
|
// Safe here only because nothing security-relevant depends on key order:
|
|
// Android's implementation preserves insertion order and the reference one
|
|
// does not, and the two bytes-must-match encodings in this engine -- the
|
|
// ratchet header and the canonical log leaf -- are both built by hand.
|
|
compileOnly("org.json:json:20240303")
|
|
testImplementation("org.json:json:20240303")
|
|
}
|
|
|
|
// Bytecode level 17, which is what Android's toolchain consumes, produced by
|
|
// whatever JDK happens to be installed. A `jvmToolchain(17)` here would demand
|
|
// a JDK 17 on every machine that builds this and fail on anything newer, which
|
|
// is a poor trade for a library with no JDK 17-specific behaviour.
|
|
kotlin {
|
|
compilerOptions {
|
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
|
}
|
|
}
|
|
|
|
java {
|
|
sourceCompatibility = JavaVersion.VERSION_17
|
|
targetCompatibility = JavaVersion.VERSION_17
|
|
}
|
|
|
|
tasks.test {
|
|
useJUnitPlatform()
|
|
testLogging {
|
|
events("passed", "failed", "skipped")
|
|
showStandardStreams = true
|
|
}
|
|
}
|