Expose the security lattice to the interop driver (relay#49) #94

Merged
paulhitt merged 1 commits from test/label-lattice-interop into main 2026-09-05 10:40:23 +00:00
@@ -12,6 +12,7 @@ import io.flamenet.e2e.PeerBundle
import io.flamenet.e2e.Ed25519
import io.flamenet.e2e.Hkdf
import io.flamenet.e2e.KeyLog
import io.flamenet.e2e.Label
import io.flamenet.e2e.SafetyNumber
import io.flamenet.e2e.SealedSender
import io.flamenet.e2e.X25519
@@ -124,6 +125,8 @@ private fun handle(req: JSONObject): JSONObject = when (req.getString("cmd")) {
req.bytes("key"), req.bytes("box"), req.bytes("aad")), Charsets.UTF_8))
"seal" -> sealSender(req)
"open_sealed" -> openSealed(req)
"label_encode" -> labelEncode(req)
"label_dominates" -> labelDominates(req)
"content_encode" -> contentEncode(req)
"content_parse" -> contentParse(req)
"verify" -> JSONObject().put("ok", Ed25519.verify(
@@ -595,3 +598,41 @@ private fun contentParse(req: JSONObject): JSONObject {
out.put("control", parsed.isControl)
return out
}
// ---------------------------------------------------------------------------
// The security lattice — §1 of the Files specification, and the core of
// relay#49's "labels and security levels are to be kept".
//
// 🔴 **The ten-byte encoding is authenticated data for every construction that
// uses a label.** An encoding that differs between platforms does not warn: it
// produces a decryption failure, or — worse — plaintext that quietly belongs to
// the wrong sensitivity. Both engines have implemented this independently since
// the Files apps shipped, and nothing has ever compared them.
//
// ⚠️ Each side's own unit tests pin their vectors, which is necessary and not
// sufficient. Two engines that each pass their own vectors can still disagree,
// and the disagreement here is silent by construction.
// ---------------------------------------------------------------------------
private fun labelFrom(json: JSONObject?): Label {
val o = json ?: JSONObject()
val bits = o.optJSONArray("compartments")?.let { a ->
(0 until a.length()).map { a.getInt(it) }
} ?: emptyList()
return Label(o.optInt("classification", 0).toShort(), bits.toSet())
}
private fun labelEncode(req: JSONObject): JSONObject {
val label = labelFrom(req)
return JSONObject()
.put("hex", label.bytes().joinToString("") { "%02x".format(it) })
// ⚠️ As a STRING. Bit 63 makes the mask a negative Long, and a JSON
// number would arrive on the other side as a double and lose the low
// bits — turning the one compartment most likely to be mis-shifted
// into the one this check cannot see.
.put("mask", label.mask.toULong().toString())
}
private fun labelDominates(req: JSONObject): JSONObject =
JSONObject().put("dominates",
labelFrom(req.optJSONObject("a")).dominates(labelFrom(req.optJSONObject("b"))))