Kotlin: the session layer, verified across the wire
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fc93df24e6
commit
8a3770f1d1
@@ -0,0 +1,218 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import org.json.JSONObject
|
||||
import java.security.KeyPair
|
||||
|
||||
/**
|
||||
* One device's long-term keys and its published prekeys (spec §3).
|
||||
*
|
||||
* A device has two identity keys, and conflating them is the classic mistake:
|
||||
* [identityDH] does key agreement and [identitySigning] does signatures.
|
||||
* X25519 and Ed25519 are related curves, but a single key used for both invites
|
||||
* cross-protocol attacks and neither JCA nor CryptoKit will hand you the same
|
||||
* object for the two jobs anyway.
|
||||
*
|
||||
* ⚠️ **Retired signed prekeys must be kept, not discarded.** A sender fetches a
|
||||
* bundle, encrypts against that `spk_id`, and the envelope can then sit
|
||||
* undelivered for the relay's full 30-day retention window. Deleting the
|
||||
* private half at rotation turns every one of those queued messages into an
|
||||
* AEAD failure — which is indistinguishable, to the receiving client, from an
|
||||
* attack. That is why [retiredSignedPreKeys] exists and why the retention
|
||||
* window here is longer than the relay's.
|
||||
*
|
||||
* A port of `engine/src/e2e.js`, which is normative. No post-quantum material
|
||||
* yet: this engine has no ML-KEM, so it publishes a v1 bundle and the peer's
|
||||
* `negotiate()` sees a v1 device, which is true.
|
||||
*/
|
||||
class DeviceIdentity private constructor(
|
||||
val deviceId: String,
|
||||
val identityDH: KeyPair,
|
||||
val identitySigning: KeyPair,
|
||||
var signedPreKey: KeyPair,
|
||||
var signedPreKeyId: Int,
|
||||
var signedPreKeyCreatedAt: Long,
|
||||
) {
|
||||
|
||||
/** A retired signed prekey, and when it stopped being published. */
|
||||
private class Retired(val keyPair: KeyPair, val retiredAt: Long)
|
||||
|
||||
private val retiredSignedPreKeys = LinkedHashMap<Int, Retired>()
|
||||
private val oneTimePreKeys = LinkedHashMap<Int, KeyPair>()
|
||||
private var nextPreKeyId = 1
|
||||
|
||||
/** One published prekey: the id a sender quotes back, and the public half. */
|
||||
data class PreKey(val id: Int, val publicKey: ByteArray)
|
||||
|
||||
companion object {
|
||||
/** The spec's "every few weeks", made a number so something can enforce it. */
|
||||
const val ROTATE_AFTER_MS = 30L * 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* How long a retired prekey stays usable. **Must exceed the relay's
|
||||
* undelivered-envelope retention** (30 days by default), with margin
|
||||
* for a sender that fetched a bundle and then went offline. Too short
|
||||
* does not fail loudly — it drops old messages.
|
||||
*/
|
||||
const val RETAIN_AFTER_MS = 45L * 24 * 60 * 60 * 1000
|
||||
|
||||
fun create(now: Long = System.currentTimeMillis()): DeviceIdentity = DeviceIdentity(
|
||||
deviceId = java.util.UUID.randomUUID().toString().lowercase(),
|
||||
identityDH = X25519.generate(),
|
||||
identitySigning = Ed25519.generate(),
|
||||
signedPreKey = X25519.generate(),
|
||||
signedPreKeyId = 1,
|
||||
signedPreKeyCreatedAt = now,
|
||||
)
|
||||
|
||||
fun fromArchive(archive: JSONObject): DeviceIdentity {
|
||||
fun x(o: JSONObject) = KeyPair(
|
||||
X25519.publicKeyFromRaw(Bytes.unb64(o.getString("pub"))),
|
||||
X25519.privateKeyFromPkcs8(Bytes.unb64(o.getString("priv"))),
|
||||
)
|
||||
val identity = DeviceIdentity(
|
||||
deviceId = archive.getString("deviceId"),
|
||||
identityDH = x(archive.getJSONObject("ikDH")),
|
||||
identitySigning = Ed25519.fromSeed(
|
||||
Bytes.unb64(archive.getJSONObject("ikSig").getString("seed"))),
|
||||
signedPreKey = x(archive.getJSONObject("spk")),
|
||||
signedPreKeyId = archive.getInt("spkId"),
|
||||
// 0 means "written before this field existed", and the honest
|
||||
// reading of an unknown age is "old" — a device that rotates
|
||||
// once on first launch after an upgrade is harmless, one that
|
||||
// never rotates because it defaulted to `now` is not.
|
||||
signedPreKeyCreatedAt = archive.optLong("spkAt", 0),
|
||||
)
|
||||
identity.nextPreKeyId = archive.optInt("nextId", 1)
|
||||
|
||||
val otp = archive.optJSONObject("otp") ?: JSONObject()
|
||||
for (key in otp.keys()) identity.oneTimePreKeys[key.toInt()] = x(otp.getJSONObject(key))
|
||||
|
||||
val previous = archive.optJSONObject("prevSpk") ?: JSONObject()
|
||||
for (key in previous.keys()) {
|
||||
val entry = previous.getJSONObject(key)
|
||||
identity.retiredSignedPreKeys[key.toInt()] =
|
||||
Retired(x(entry), entry.optLong("retiredAt", 0))
|
||||
}
|
||||
return identity
|
||||
}
|
||||
}
|
||||
|
||||
val identityDHPublic: ByteArray get() = X25519.rawFromPublicKey(identityDH.public)
|
||||
val identitySigningPublic: ByteArray get() = Ed25519.rawFromPublicKey(identitySigning.public)
|
||||
|
||||
/**
|
||||
* The signature a peer checks before trusting the signed prekey.
|
||||
*
|
||||
* This is the whole reason a relay cannot quietly substitute its own prekey
|
||||
* and read everything — provided the peer actually verifies it, which is
|
||||
* why [E2ESession.initiate] does, unconditionally.
|
||||
*/
|
||||
fun signedPreKeySignature(): ByteArray =
|
||||
Ed25519.sign(identitySigning.private, X25519.rawFromPublicKey(signedPreKey.public))
|
||||
|
||||
fun generateOneTimePreKeys(count: Int): List<PreKey> = (0 until count).map {
|
||||
val id = nextPreKeyId++
|
||||
val pair = X25519.generate()
|
||||
oneTimePreKeys[id] = pair
|
||||
PreKey(id, X25519.rawFromPublicKey(pair.public))
|
||||
}
|
||||
|
||||
/** Consume a one-time prekey. Consumed exactly once — that is the point of it. */
|
||||
fun takeOneTimePreKey(id: Int): KeyPair? =
|
||||
if (id < 0) null else oneTimePreKeys.remove(id)
|
||||
|
||||
val oneTimePreKeyCount: Int get() = oneTimePreKeys.size
|
||||
|
||||
/**
|
||||
* The signed prekey a prekey envelope refers to: the current one, or a
|
||||
* retired one still inside the retention window.
|
||||
*
|
||||
* An id of 0 means the sender did not state one — true of every envelope
|
||||
* built before rotation existed — and those can only have used the current
|
||||
* key, so falling back is correct rather than lax. An id that IS stated and
|
||||
* unknown returns null, because guessing would derive a garbage shared
|
||||
* secret and report it as tampering.
|
||||
*/
|
||||
fun signedPreKeyFor(id: Int): KeyPair? = when {
|
||||
id == 0 || id == signedPreKeyId -> signedPreKey
|
||||
else -> retiredSignedPreKeys[id]?.keyPair
|
||||
}
|
||||
|
||||
fun shouldRotateSignedPreKey(now: Long = System.currentTimeMillis()): Boolean =
|
||||
now - signedPreKeyCreatedAt >= ROTATE_AFTER_MS
|
||||
|
||||
/**
|
||||
* Retire the current signed prekey and mint a new one.
|
||||
*
|
||||
* ⚠️ The caller must republish the bundle and persist afterwards. Rotating
|
||||
* without republishing leaves the relay handing out a bundle whose private
|
||||
* half has moved; rotating without persisting loses the retired key on the
|
||||
* next launch, which is the message-loss case above.
|
||||
*/
|
||||
fun rotateSignedPreKey(now: Long = System.currentTimeMillis()): KeyPair {
|
||||
retiredSignedPreKeys[signedPreKeyId] = Retired(signedPreKey, now)
|
||||
signedPreKey = X25519.generate()
|
||||
signedPreKeyId += 1
|
||||
signedPreKeyCreatedAt = now
|
||||
pruneSignedPreKeys(now)
|
||||
return signedPreKey
|
||||
}
|
||||
|
||||
/** Drop retired prekeys no queued envelope can still refer to. */
|
||||
fun pruneSignedPreKeys(now: Long = System.currentTimeMillis()) {
|
||||
retiredSignedPreKeys.entries.removeAll { now - it.value.retiredAt > RETAIN_AFTER_MS }
|
||||
}
|
||||
|
||||
/** The body of `POST /e2e/devices`. */
|
||||
fun registrationWire(oneTime: List<PreKey> = emptyList()): JSONObject = JSONObject()
|
||||
.put("device_id", deviceId)
|
||||
.put("ik_dh", Bytes.b64(identityDHPublic))
|
||||
.put("ik_sig", Bytes.b64(identitySigningPublic))
|
||||
.put("spk_id", signedPreKeyId)
|
||||
.put("spk", Bytes.b64(X25519.rawFromPublicKey(signedPreKey.public)))
|
||||
.put("spk_sig", Bytes.b64(signedPreKeySignature()))
|
||||
.put("proto", 1)
|
||||
.put("opks", org.json.JSONArray().also { array ->
|
||||
oneTime.forEach {
|
||||
array.put(JSONObject().put("id", it.id).put("pub", Bytes.b64(it.publicKey)))
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Everything that must survive a restart.
|
||||
*
|
||||
* ⚠️ **This is private key material.** It is the vault's job to encrypt it;
|
||||
* this returns it in the clear because a store that cannot see the plaintext
|
||||
* cannot store it. Never log this, never write it to shared preferences.
|
||||
*
|
||||
* Retired prekeys travel with it, for the reason at the top of this file.
|
||||
*/
|
||||
fun archive(): JSONObject {
|
||||
fun x(pair: KeyPair) = JSONObject()
|
||||
.put("priv", Bytes.b64(pair.private.encoded))
|
||||
.put("pub", Bytes.b64(X25519.rawFromPublicKey(pair.public)))
|
||||
|
||||
val otp = JSONObject()
|
||||
oneTimePreKeys.forEach { (id, pair) -> otp.put(id.toString(), x(pair)) }
|
||||
val previous = JSONObject()
|
||||
retiredSignedPreKeys.forEach { (id, entry) ->
|
||||
previous.put(id.toString(), x(entry.keyPair).put("retiredAt", entry.retiredAt))
|
||||
}
|
||||
|
||||
return JSONObject()
|
||||
.put("deviceId", deviceId)
|
||||
.put("ikDH", x(identityDH))
|
||||
// Ed25519 is stored as its 32-byte seed rather than a PKCS#8 blob,
|
||||
// because that is the one form both the JCA and a passphrase-derived
|
||||
// account key agree on.
|
||||
.put("ikSig", JSONObject()
|
||||
.put("seed", Bytes.b64(Ed25519.seedOf(identitySigning.private)))
|
||||
.put("pub", Bytes.b64(identitySigningPublic)))
|
||||
.put("spk", x(signedPreKey))
|
||||
.put("spkId", signedPreKeyId)
|
||||
.put("spkAt", signedPreKeyCreatedAt)
|
||||
.put("nextId", nextPreKeyId)
|
||||
.put("otp", otp)
|
||||
.put("prevSpk", previous)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import org.json.JSONObject
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
@@ -84,6 +85,16 @@ class DoubleRatchet private constructor(
|
||||
*/
|
||||
const val MAX_SKIP = 1000
|
||||
|
||||
/**
|
||||
* How many skipped keys are kept across the whole session.
|
||||
*
|
||||
* `MAX_SKIP` bounds one header; without a second bound, a peer sending
|
||||
* a thousand-message gap over and over grows this map forever. Oldest
|
||||
* out first — a key that has waited through two thousand later messages
|
||||
* is not arriving.
|
||||
*/
|
||||
const val MAX_STORE = 2000
|
||||
|
||||
/** Matches `Aead`'s GCM nonce length; the ratchet strips it on the wire. */
|
||||
private const val NONCE_LEN = 12
|
||||
|
||||
@@ -111,6 +122,35 @@ class DoubleRatchet private constructor(
|
||||
sent = 0, received = 0, previousChainLength = 0,
|
||||
)
|
||||
|
||||
/** The other half of [archive]. */
|
||||
fun fromArchive(archive: JSONObject): DoubleRatchet {
|
||||
fun opt(key: String) = archive.optString(key, "").takeIf { it.isNotEmpty() }?.let(Bytes::unb64)
|
||||
val selfPrivate = opt("dhs")
|
||||
val selfPublic = opt("dhsPub")
|
||||
val ratchet = DoubleRatchet(
|
||||
rootKey = Bytes.unb64(archive.getString("rk")),
|
||||
sendingChain = opt("cks"),
|
||||
receivingChain = opt("ckr"),
|
||||
selfRatchet = if (selfPrivate != null && selfPublic != null) {
|
||||
java.security.KeyPair(
|
||||
X25519.publicKeyFromRaw(selfPublic),
|
||||
X25519.privateKeyFromPkcs8(selfPrivate),
|
||||
)
|
||||
} else null,
|
||||
remoteRatchet = opt("dhr"),
|
||||
sent = archive.getInt("ns"),
|
||||
received = archive.getInt("nr"),
|
||||
previousChainLength = archive.getInt("pn"),
|
||||
)
|
||||
val skipped = archive.optJSONArray("skipped") ?: org.json.JSONArray()
|
||||
for (i in 0 until skipped.length()) {
|
||||
val entry = skipped.getJSONObject(i)
|
||||
ratchet.skipped[SkippedKey(entry.getString("dh"), entry.getInt("n"))] =
|
||||
Bytes.unb64(entry.getString("mk"))
|
||||
}
|
||||
return ratchet
|
||||
}
|
||||
|
||||
private fun rootStep(rootKey: ByteArray, dhOutput: ByteArray): Pair<ByteArray, ByteArray> {
|
||||
val out = Hkdf.derive(dhOutput, rootKey, RATCHET_INFO, 64)
|
||||
return out.copyOfRange(0, 32) to out.copyOfRange(32, 64)
|
||||
@@ -197,6 +237,9 @@ class DoubleRatchet private constructor(
|
||||
while (n < target) {
|
||||
val (mk, next) = chainStep(ck)
|
||||
skipped[SkippedKey(remote, n)] = mk
|
||||
while (skipped.size > MAX_STORE) {
|
||||
skipped.remove(skipped.keys.first())
|
||||
}
|
||||
ck = next
|
||||
n += 1
|
||||
}
|
||||
@@ -224,4 +267,38 @@ class DoubleRatchet private constructor(
|
||||
rootKey = stepped.first
|
||||
sendingChain = stepped.second
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the conversation needs to continue after a restart.
|
||||
*
|
||||
* ⚠️ A ratchet that is not persisted is not a bug you notice — it is a bug
|
||||
* your *contacts* notice. A client that loses session state falls back to
|
||||
* minting a new device identity, and everyone who has ever talked to that
|
||||
* account sees "security keys changed", which is the warning that is
|
||||
* supposed to mean an attack.
|
||||
*
|
||||
* Skipped message keys travel too. They are the keys for messages that have
|
||||
* not arrived yet, and dropping them at a restart loses exactly the
|
||||
* messages that were already in flight.
|
||||
*/
|
||||
fun archive(): JSONObject {
|
||||
val skippedJson = org.json.JSONArray()
|
||||
skipped.forEach { (key, messageKey) ->
|
||||
skippedJson.put(JSONObject()
|
||||
.put("dh", key.ratchetPublic)
|
||||
.put("n", key.index)
|
||||
.put("mk", Bytes.b64(messageKey)))
|
||||
}
|
||||
return JSONObject()
|
||||
.put("rk", Bytes.b64(rootKey))
|
||||
.put("cks", sendingChain?.let { Bytes.b64(it) })
|
||||
.put("ckr", receivingChain?.let { Bytes.b64(it) })
|
||||
.put("dhs", selfRatchet?.let { Bytes.b64(it.private.encoded) })
|
||||
.put("dhsPub", selfRatchet?.let { Bytes.b64(X25519.rawFromPublicKey(it.public)) })
|
||||
.put("dhr", remoteRatchet?.let { Bytes.b64(it) })
|
||||
.put("ns", sent)
|
||||
.put("nr", received)
|
||||
.put("pn", previousChainLength)
|
||||
.put("skipped", skippedJson)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* One conversation with one device (spec §8.1).
|
||||
*
|
||||
* This is the layer an app actually uses: [initiate] or [respond] once, then
|
||||
* [encrypt] and [decrypt] strings. Everything below it — the handshake, the
|
||||
* ratchet, the associated data — is set up here and then never thought about
|
||||
* again, which is the point.
|
||||
*
|
||||
* ⚠️ **The first messages are `prekey` envelopes and must stay that way until
|
||||
* the peer replies.** Not until the first one is *sent* — until one is
|
||||
* *answered*. Any single message may be the first to arrive, so all of them
|
||||
* have to carry the handshake block; sending it once means losing that one
|
||||
* message strands the whole conversation, and the symptom is a contact whose
|
||||
* messages simply never appear.
|
||||
*
|
||||
* A port of `engine/src/e2e.js`, which is normative, and verified against it by
|
||||
* `engine/test/interop-kotlin.mjs` rather than by reading.
|
||||
*/
|
||||
class E2ESession private constructor(
|
||||
private val ratchet: DoubleRatchet,
|
||||
/** Bound into every message's AEAD tag: the two identity keys, initiator first. */
|
||||
val associatedData: ByteArray,
|
||||
private var pendingHandshake: JSONObject?,
|
||||
val version: Int,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* Start a conversation from a bundle fetched for a peer.
|
||||
*
|
||||
* ⚠️ **The signed prekey's signature is verified here, unconditionally.**
|
||||
* The bundle arrives from a relay, and a relay that can substitute an
|
||||
* unverified prekey can read the entire conversation. This is the single
|
||||
* check that stops that, so it is not optional, not configurable, and
|
||||
* not the caller's job to remember.
|
||||
*/
|
||||
fun initiate(identity: DeviceIdentity, bundle: PeerBundle): E2ESession {
|
||||
if (!Ed25519.verify(bundle.identitySigning, bundle.signedPreKeySignature, bundle.signedPreKey)) {
|
||||
throw SecurityException("the peer's signed prekey is not signed by its identity key")
|
||||
}
|
||||
|
||||
val initiated = X3DH.initiate(
|
||||
identityPrivateDH = identity.identityDH.private,
|
||||
bundle = X3DH.Bundle(
|
||||
identityKeyDH = bundle.identityDH,
|
||||
signedPreKey = bundle.signedPreKey,
|
||||
oneTimePreKey = bundle.oneTimePreKey,
|
||||
),
|
||||
)
|
||||
|
||||
val handshake = JSONObject()
|
||||
.put("ik_dh", Bytes.b64(identity.identityDHPublic))
|
||||
.put("ik_sig", Bytes.b64(identity.identitySigningPublic))
|
||||
.put("ek", Bytes.b64(initiated.ephemeralPublic))
|
||||
.put("spk_id", bundle.signedPreKeyId)
|
||||
.put("opk_id", bundle.oneTimePreKeyId)
|
||||
|
||||
return E2ESession(
|
||||
ratchet = DoubleRatchet.initiator(initiated.sharedSecret, bundle.signedPreKey),
|
||||
associatedData = identity.identitySigningPublic + bundle.identitySigning,
|
||||
pendingHandshake = handshake,
|
||||
version = 1,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer a `prekey` envelope.
|
||||
*
|
||||
* ⚠️ The ratchet seeds from the signed prekey **X3DH actually used**,
|
||||
* which may be a retired one. Seeding from the current key while X3DH
|
||||
* used a retired one desynchronises the very first DH step, and the
|
||||
* failure appears on the peer's screen, not here.
|
||||
*/
|
||||
fun respond(identity: DeviceIdentity, envelope: Envelope): E2ESession {
|
||||
val handshake = envelope.handshake
|
||||
?: throw IllegalArgumentException("not a prekey envelope: no x3dh block")
|
||||
|
||||
val signedPreKeyId = handshake.optInt("spk_id", 0)
|
||||
val signedPreKey = identity.signedPreKeyFor(signedPreKeyId)
|
||||
?: throw IllegalArgumentException(
|
||||
"unknown signed prekey $signedPreKeyId: retired past retention, or fabricated")
|
||||
|
||||
val theirIdentityDH = Bytes.unb64(handshake.getString("ik_dh"))
|
||||
val theirIdentitySigning = Bytes.unb64(handshake.getString("ik_sig"))
|
||||
|
||||
val sharedSecret = X3DH.respond(
|
||||
identityPrivateDH = identity.identityDH.private,
|
||||
signedPreKeyPrivate = signedPreKey.private,
|
||||
oneTimePrivate = identity.takeOneTimePreKey(handshake.optInt("opk_id", -1))?.private,
|
||||
initiatorIdentityDH = theirIdentityDH,
|
||||
initiatorEphemeral = Bytes.unb64(handshake.getString("ek")),
|
||||
)
|
||||
|
||||
val session = E2ESession(
|
||||
ratchet = DoubleRatchet.responder(sharedSecret, signedPreKey),
|
||||
associatedData = theirIdentitySigning + identity.identitySigningPublic,
|
||||
pendingHandshake = null,
|
||||
version = 1,
|
||||
)
|
||||
return session
|
||||
}
|
||||
|
||||
/** Restore a session written by [archive]. */
|
||||
fun fromArchive(archive: JSONObject): E2ESession = E2ESession(
|
||||
ratchet = DoubleRatchet.fromArchive(archive.getJSONObject("ratchet")),
|
||||
associatedData = Bytes.unb64(archive.getString("ad")),
|
||||
pendingHandshake = archive.optJSONObject("pending"),
|
||||
version = archive.optInt("version", 1),
|
||||
)
|
||||
}
|
||||
|
||||
fun encrypt(text: String): Envelope {
|
||||
val message = ratchet.encrypt(text.toByteArray(Charsets.UTF_8), associatedData)
|
||||
return Envelope(
|
||||
version = version,
|
||||
type = if (pendingHandshake != null) "prekey" else "msg",
|
||||
header = message.header,
|
||||
handshake = pendingHandshake,
|
||||
ciphertext = message.ciphertext,
|
||||
)
|
||||
}
|
||||
|
||||
fun decrypt(envelope: Envelope): String {
|
||||
val plaintext = ratchet.decrypt(
|
||||
DoubleRatchet.Message(envelope.header, envelope.ciphertext), associatedData)
|
||||
// Only now — a reply proves the peer has the handshake, which is the
|
||||
// one moment it is safe to stop repeating it.
|
||||
pendingHandshake = null
|
||||
return String(plaintext, Charsets.UTF_8)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ [pendingHandshake] has to be archived too. It is the only copy of the
|
||||
* ephemeral public key the handshake block contains, and regenerating it
|
||||
* after a restart would produce a different block — a different session,
|
||||
* from the peer's point of view.
|
||||
*/
|
||||
fun archive(): JSONObject = JSONObject()
|
||||
.put("ratchet", ratchet.archive())
|
||||
.put("ad", Bytes.b64(associatedData))
|
||||
.put("pending", pendingHandshake)
|
||||
.put("version", version)
|
||||
}
|
||||
|
||||
/**
|
||||
* A peer's published keys, as fetched from `GET /e2e/keys`.
|
||||
*
|
||||
* ⚠️ `oneTimePreKey` being null is normal, not an error: the pool drains and
|
||||
* the relay serves what is left. It costs initial forward secrecy for that one
|
||||
* session and nothing else.
|
||||
*/
|
||||
data class PeerBundle(
|
||||
val deviceId: String,
|
||||
val identityDH: ByteArray,
|
||||
val identitySigning: ByteArray,
|
||||
val signedPreKeyId: Int,
|
||||
val signedPreKey: ByteArray,
|
||||
val signedPreKeySignature: ByteArray,
|
||||
val oneTimePreKeyId: Int,
|
||||
val oneTimePreKey: ByteArray?,
|
||||
val proto: Int,
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* Read the relay's wire shape.
|
||||
*
|
||||
* ⚠️ The one bug this protocol has actually shipped was a client and a
|
||||
* relay disagreeing about field names, and nothing caught it until a
|
||||
* live round trip. So the translation lives in exactly one place, and
|
||||
* that place is tested.
|
||||
*/
|
||||
fun fromWire(wire: JSONObject): PeerBundle {
|
||||
val oneTime = wire.optJSONObject("opk")
|
||||
return PeerBundle(
|
||||
deviceId = wire.getString("device_id"),
|
||||
identityDH = Bytes.unb64(wire.getString("ik_dh")),
|
||||
identitySigning = Bytes.unb64(wire.getString("ik_sig")),
|
||||
signedPreKeyId = wire.optInt("spk_id", 0),
|
||||
signedPreKey = Bytes.unb64(wire.getString("spk")),
|
||||
signedPreKeySignature = Bytes.unb64(wire.getString("spk_sig")),
|
||||
oneTimePreKeyId = oneTime?.optInt("id", -1) ?: -1,
|
||||
oneTimePreKey = oneTime?.let { Bytes.unb64(it.getString("pub")) },
|
||||
proto = wire.optInt("proto", 1),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One message on the wire (spec §8.2). */
|
||||
data class Envelope(
|
||||
val version: Int,
|
||||
val type: String,
|
||||
val header: DoubleRatchet.Header,
|
||||
val handshake: JSONObject?,
|
||||
val ciphertext: ByteArray,
|
||||
) {
|
||||
fun toJson(): JSONObject = JSONObject()
|
||||
.put("v", version)
|
||||
.put("type", type)
|
||||
.put("header", JSONObject()
|
||||
.put("dh", Bytes.b64(header.dh))
|
||||
.put("pn", header.pn)
|
||||
.put("n", header.n))
|
||||
.put("x3dh", handshake)
|
||||
.put("ct", Bytes.b64(ciphertext))
|
||||
|
||||
/**
|
||||
* The relay carries an envelope as one opaque base64 string, so it never
|
||||
* sees these fields at all.
|
||||
*/
|
||||
fun toPayload(): String = Bytes.b64(toJson().toString().toByteArray(Charsets.UTF_8))
|
||||
|
||||
companion object {
|
||||
fun fromJson(json: JSONObject): Envelope {
|
||||
val header = json.getJSONObject("header")
|
||||
return Envelope(
|
||||
version = json.optInt("v", 1),
|
||||
type = json.optString("type", "msg"),
|
||||
header = DoubleRatchet.Header(
|
||||
dh = Bytes.unb64(header.getString("dh")),
|
||||
pn = header.getInt("pn"),
|
||||
n = header.getInt("n"),
|
||||
),
|
||||
handshake = json.optJSONObject("x3dh"),
|
||||
ciphertext = Bytes.unb64(json.getString("ct")),
|
||||
)
|
||||
}
|
||||
|
||||
fun fromPayload(payload: String): Envelope =
|
||||
fromJson(JSONObject(String(Bytes.unb64(payload), Charsets.UTF_8)))
|
||||
}
|
||||
}
|
||||
@@ -124,8 +124,14 @@ object Ed25519 {
|
||||
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 {
|
||||
/**
|
||||
* The 32 seed bytes back out of a PKCS#8 encoding.
|
||||
*
|
||||
* Public because the vault stores a seed, not a DER blob: it is the one
|
||||
* representation the JCA, CryptoKit and a passphrase-derived account key
|
||||
* all agree on.
|
||||
*/
|
||||
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}"
|
||||
|
||||
@@ -3,6 +3,7 @@ package io.flamenet.e2e
|
||||
import java.security.KeyFactory
|
||||
import java.security.KeyPair
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import java.security.spec.X509EncodedKeySpec
|
||||
import javax.crypto.KeyAgreement
|
||||
|
||||
@@ -52,6 +53,18 @@ object X25519 {
|
||||
.generatePublic(X509EncodedKeySpec(SPKI_PREFIX + raw))
|
||||
}
|
||||
|
||||
/**
|
||||
* A private key back from its PKCS#8 encoding, which is what the vault
|
||||
* stores.
|
||||
*
|
||||
* PKCS#8 rather than the raw 32 scalar bytes for the same reason public
|
||||
* keys go through DER: Conscrypt will not build an XDH key from raw bytes.
|
||||
* Storing raw and wrapping at load time would work on a laptop and throw on
|
||||
* a phone, which is the exact bug this engine has already had once.
|
||||
*/
|
||||
fun privateKeyFromPkcs8(der: ByteArray): java.security.PrivateKey =
|
||||
KeyFactory.getInstance("XDH").generatePrivate(PKCS8EncodedKeySpec(der))
|
||||
|
||||
/** The raw 32 bytes the wire carries, from a JCA public key. */
|
||||
fun rawFromPublicKey(key: java.security.PublicKey): ByteArray {
|
||||
val encoded = key.encoded
|
||||
|
||||
Reference in New Issue
Block a user