The Files security lattice in Kotlin (labels, clearance)

`Label` and `Clearance`: the two-dimensional lattice Files is built on, and the
ten-byte encoding that is authenticated data for every construction using it.

🔴 The encoding vectors were computed independently, not read off this
implementation. A label that encodes differently on two platforms does not warn
— it produces a decryption failure, or plaintext that quietly belongs to the
wrong sensitivity. A vector taken from the code under test would pin whatever
the code happens to do.

🔴 Two dimensions, and the tests are shaped around the failure that READS AS
WORKING: an implementation comparing classification and forgetting compartments
behaves correctly on every test built from levels alone, right up until
somebody sees a compartment they are not in. Domination needs a superset of
areas, not an overlap; two labels can be incomparable in both directions.

⚠️ Bit 63 is the sign bit of a Long. Building the mask with an Int shift loses
it, and the compartment that vanishes is the one nobody notices until somebody
sees a file they are not cleared for. Pinned, along with the all-ones case where
a sign-extension mistake still produces plausible-looking bytes.

🔴 **A finding, and a divergence I deliberately did NOT create.** Files
implements the simple security property (no read up) and NOT the ★-property (no
write down) — on either client. iOS gates `ensureLabelKey` and `use` on
`clearance.permits` and nothing else, so Kotlin's `mayWrite` calls the same
check, with a comment saying why and a test asserting the current behaviour that
will fail loudly the day it changes.

Implementing it on Android alone would mean a file that saves on one phone and
is refused on the other, with neither person able to tell which rule they had
met — worse than the gap, because an apparent bug gets worked around. Filed as
android#15 / ios#65 with the trade written out: no-write-down is the half a
lattice actually exists for, and it is also the half that is awkward enough to
need a stated downgrade path rather than a bare refusal. It interacts with
sharing a file into a chat — a write to a place with no label at all — so it
needs an answer before that ships.

16 tests. App builds, every JVM suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-04 14:53:26 -04:00
co-authored by Claude Opus 5
parent d915001dcc
commit 6a0dce6d4e
3 changed files with 298 additions and 1 deletions
@@ -0,0 +1,196 @@
package io.flamenet.e2e
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* The security lattice, and the two Bell–LaPadula rules it exists to enforce.
*
* 🔴 **The encoding vectors were computed independently, not read off this
* implementation.** The ten bytes are authenticated data for every construction
* that uses a label, so an encoding that differs between platforms does not warn
* — it produces a decryption failure, or plaintext that quietly belongs to the
* wrong sensitivity. A vector taken from the code under test would pin whatever
* the code happens to do.
*/
class LabelTest {
private fun hex(bytes: ByteArray) = bytes.joinToString("") { "%02x".format(it) }
// ---- The encoding ----------------------------------------------------
/** `int16be classification || uint64be compartment mask`. */
@Test
fun `the ten-byte encoding matches the specification`() {
assertEquals("00000000000000000000", hex(Label(0).bytes()))
assertEquals("00010000000000000000", hex(Label(1).bytes()))
assertEquals("00038000000000000001", hex(Label(3, setOf(0, 63)).bytes()))
assertEquals("01000000000000000100", hex(Label(256, setOf(8)).bytes()))
}
/**
* ⚠️ A negative classification and a full compartment mask together — the
* case where a sign-extension mistake or an `Int` in place of a `Long`
* shows up. Both halves are all-ones, so a wrong shift produces something
* that still looks plausible.
*/
@Test
fun `the extremes encode without sign-extension damage`() {
assertEquals("ffffffffffffffffffff", hex(Label(-1, (0..63).toSet()).bytes()))
}
/**
* 🔴 Bit 63 is the sign bit of a Long. Building the mask with an `Int`
* shift, or reading it back as one, loses it — and the compartment that
* vanishes is the one nobody notices until somebody sees a file they are
* not cleared for.
*/
@Test
fun `the top compartment bit survives`() {
assertEquals("00008000000000000000", hex(Label(0, setOf(63)).bytes()))
assertTrue(Label(0, setOf(63)).mask != 0L)
}
@Test
fun `the encoding is always ten bytes`() {
assertEquals(10, Label(0).bytes().size)
assertEquals(10, Label(-1, (0..63).toSet()).bytes().size)
}
@Test
fun `a compartment outside the range is refused`() {
assertFailsWith<IllegalArgumentException> { Label(0, setOf(64)) }
assertFailsWith<IllegalArgumentException> { Label(0, setOf(-1)) }
}
// ---- Domination ------------------------------------------------------
@Test
fun `a higher classification dominates a lower one`() {
assertTrue(Label(3).dominates(Label(1)))
assertFalse(Label(1).dominates(Label(3)))
assertTrue(Label(2).dominates(Label(2)))
}
/**
* 🔴 **The failure that reads as working.** An implementation that compares
* the classification and forgets the compartments behaves correctly on
* every test built from levels alone — right up until somebody sees a
* compartment they are not in.
*/
@Test
fun `a higher level does NOT dominate a compartment it is not in`() {
val topSecretNoAreas = Label(9)
val secretInArea = Label(5, setOf(2))
assertFalse(
topSecretNoAreas.dominates(secretInArea),
"a higher classification reached into a compartment it does not hold",
)
}
@Test
fun `domination needs a superset of areas, not an overlap`() {
val holder = Label(5, setOf(1, 2))
assertTrue(holder.dominates(Label(5, setOf(1))))
assertTrue(holder.dominates(Label(5, setOf(1, 2))))
assertFalse(holder.dominates(Label(5, setOf(1, 3))),
"an overlapping but incomplete set was treated as domination")
}
/** Two labels can be incomparable — neither dominates the other. */
@Test
fun `disjoint compartments are incomparable in both directions`() {
val a = Label(5, setOf(1))
val b = Label(5, setOf(2))
assertFalse(a.dominates(b))
assertFalse(b.dominates(a))
}
@Test
fun `bottom is dominated by everything`() {
assertTrue(Label(0).dominates(Label.bottom))
assertTrue(Label(9, setOf(3)).dominates(Label.bottom))
}
// ---- Clearance: no read up -------------------------------------------
@Test
fun `a clearance reads what it dominates`() {
val cleared = Clearance(maximum = Label(5, setOf(1, 2)))
assertTrue(cleared.mayRead(Label(3, setOf(1))))
assertTrue(cleared.mayRead(Label(5, setOf(1, 2))))
}
@Test
fun `a clearance cannot read above itself`() {
val cleared = Clearance(maximum = Label(5))
assertFalse(cleared.mayRead(Label(6)))
assertFalse(cleared.mayRead(Label(5, setOf(1))),
"a compartment outside the clearance was readable")
}
/**
* 🔴 **The minimum is a real bound, not decoration.** A label must sit
* INSIDE the range. Somebody cleared for sensitive work is deliberately not
* also holding the unclassified pile — which sounds like a convenience to
* remove until it is the reason an audit can say who could have seen what.
*/
@Test
fun `a clearance with a floor cannot read below it`() {
val cleared = Clearance(maximum = Label(5), minimum = Label(2))
assertTrue(cleared.mayRead(Label(3)))
assertFalse(cleared.mayRead(Label(1)), "a label below the floor was readable")
}
// ---- Writing ---------------------------------------------------------
/**
* 🔴 **Writing is gated by the same range check as reading, and the
* ★-property is NOT enforced — on either platform.**
*
* Bell–LaPadula says somebody cleared to read sensitive material must not
* write *below* their clearance, because they would carry what they read
* down with them. iOS gates `ensureLabelKey` and `use` on
* `clearance.permits` and nothing else, so this matches it deliberately.
*
* Implementing the rule on Android alone would produce a file that saves on
* one phone and is refused on the other, with neither person able to tell
* which rule they had met — worse than the gap itself. Filed as android#15 /
* ios#65 so it is decided once, for both.
*
* ⚠️ This asserts the CURRENT behaviour, including the part that is a gap.
* It should fail loudly on the day somebody implements the ★-property,
* which is the point of writing the absence down rather than leaving it
* implicit.
*/
@Test
fun `writing uses the same range check as reading, star-property not enforced`() {
val cleared = Clearance(maximum = Label(5))
assertTrue(cleared.mayWrite(Label(5)))
assertTrue(
cleared.mayWrite(Label(1)),
"the star-property is now enforced — see android#15, and update the " +
"iOS side and this test together",
)
assertFalse(cleared.mayWrite(Label(8)))
assertFalse(cleared.mayRead(Label(8)))
}
@Test
fun `a floor bounds writing as well as reading`() {
val cleared = Clearance(maximum = Label(5), minimum = Label(2))
assertTrue(cleared.mayWrite(Label(3)))
assertFalse(cleared.mayWrite(Label(1)))
}
@Test
fun `a compartment outside the clearance cannot be written either`() {
val cleared = Clearance(maximum = Label(5, setOf(1)))
assertTrue(cleared.mayWrite(Label(5, setOf(1))))
assertFalse(cleared.mayWrite(Label(5, setOf(1, 2))),
"a compartment outside the clearance was writable")
}
}