Merge branch 'feat/files-local-vault' into 'main'

The local Files vault in Kotlin

See merge request paulhitt/flamenet-messenger-android!51
This commit was merged in pull request #78.
This commit is contained in:
Paul Hitt
2026-09-04 20:49:55 +00:00
3 changed files with 1114 additions and 1 deletions
+1 -1
View File
@@ -338,7 +338,7 @@ the messenger rather than staying separate apps.
|---|:--:|
| Files engine: names, key wrapping, chunked content | ✅ pinned to the **browser's** published vectors, not to the Swift engine |
| Files engine: KDF and identity record | ✅ an identity written by the browser unlocks here |
| Local vault: add, organise, delete | ⚪ |
| Local vault: add, organise, delete | ✅ index, folders, both storage modes, bulk delete with its report |
| Bell–LaPadula labels and clearance | 🟡 lattice, encoding and clearance in Kotlin, pinned to independently-computed vectors; the ★-property is unenforced on **both** clients — android#15 |
| Share a file into a chat or group | ⚪ |
| Wallet engine: BIP-39, Base58, SLIP-0010 | ✅ pinned to the published vectors |
@@ -0,0 +1,600 @@
package io.flamenet.client
import io.flamenet.e2e.Aead
import io.flamenet.e2e.FileContent
import io.flamenet.e2e.FileKeys
import io.flamenet.e2e.FileNames
import io.flamenet.e2e.FilesEngineException
import io.flamenet.e2e.FilesKdf
import io.flamenet.e2e.Hkdf
import io.flamenet.e2e.Label
import io.flamenet.e2e.Random
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.util.Base64
import java.util.UUID
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import org.json.JSONArray
import org.json.JSONObject
/**
* Files that live on this phone.
*
* 🔴 **The server is not storage any more.** It is a courier: it holds
* ciphertext for as long as it takes to reach the other device, and then it does
* not. Everything a person owns is here, in this app's own container, and a
* phone that never syncs still has all of it.
*
* What that costs is worth stating plainly, because it changed and it changed
* silently everywhere else:
*
* ⚠️ **Read-down is no longer enforced by anything that can refuse.** When the
* rows lived in Postgres, `session_dominates(classification, compartments)` was
* an RLS predicate — the database itself would not return a row above your
* clearance, whatever the client asked for. There is no database here. A label
* still gates *decryption*, absolutely: without the label key the bytes are
* bytes. But nothing stops a sender transferring a file above a recipient's
* level, and the clearance shown in the interface now means "the keys this
* device holds", not "what a server will show you". That is a deliberate trade,
* and the interface must not keep using the old words for the new thing.
*
* ⚠️ **The index file is device-local and is NOT an interchange format.** The
* node *fields* match iOS's exactly, deliberately, so that a courier transfer is
* a copy rather than a translation. The JSON around them is not: iOS encodes
* dates as Swift's reference-date doubles, which is an accident of `JSONEncoder`
* rather than a decision, and pinning Kotlin to it would make an accident into a
* format. Anybody building vault migration between phones should move nodes
* through a declared wire shape, not by copying this file.
*
* ⚠️ **Synchronous, and it touches the disk.** Call it off the main thread. The
* lock below makes concurrent calls safe, not fast — a `ReentrantLock` rather
* than a coroutine `Mutex` because this module deliberately has no coroutines
* dependency, which is what lets its tests run without an emulator.
*/
class LocalVault(
private val root: File,
/** The raw 32-byte X25519 private key from an unlocked [io.flamenet.e2e.FilesIdentity]. */
private val identityRawPrivate: ByteArray,
/**
* How a referenced file is reached.
*
* 🔴 Required, with no default. A no-op default would make every reference
* row read as "missing" in an app that simply forgot to wire this up — a
* vault that looks like it has lost half its contents, with nothing
* anywhere saying why.
*/
private val references: ReferenceResolver,
) {
/**
* Where a row's bytes actually are.
*
* 🔴 The two are not equally protected and the interface must not present
* them as if they were.
*
* - [VAULT]: copied in and encrypted under the label key. A stolen backup or
* container yields ciphertext, and the classification is a control.
* - [REFERENCE]: a pointer to a file that stays where it is on the phone,
* encrypted only at the moment it is sent. Nothing is duplicated, and the
* file keeps whatever protection the platform gives it — but we are not
* adding any, the label is advisory until it is sent, and the row breaks
* if the file is moved or deleted.
*/
enum class Storage(val wire: String) {
VAULT("vault"),
REFERENCE("reference");
companion object {
/**
* ⚠️ Absence means [VAULT], and that is not a nicety. iOS shipped a
* `storage` field with a Swift default and discovered that a default
* does not make a `Codable` key optional — every index written
* before referencing existed failed to decode, which presents as a
* vault that has lost everything. Reading an unknown or missing
* value as `vault` is what those rows actually are.
*/
fun parse(raw: String?): Storage =
entries.firstOrNull { it.wire == raw } ?: VAULT
}
}
/**
* How a referenced file is reached again after a relaunch.
*
* The [bookmark] is opaque here on purpose. On Android it is a Storage
* Access Framework document URI whose read permission was persisted; on iOS
* it is a security-scoped bookmark. Neither belongs in this module, which
* has no platform in it.
*/
interface ReferenceResolver {
/**
* ⚠️ Whether the file is still there — asked separately from reading it,
* because a row that silently does nothing when tapped is worse than one
* that says the file has moved.
*/
fun exists(bookmark: String): Boolean
/** The bytes. Throws if the file has gone since [exists] said otherwise. */
fun read(bookmark: String): ByteArray
/** Size in bytes, or 0 when the platform will not say. */
fun size(bookmark: String): Int
}
/** A row. Mirrors what the server's `nodes` table held, field for field. */
data class Node(
val id: UUID,
val parentId: UUID?,
val isFolder: Boolean,
/**
* In the clear, as it was in the database: a label is not a secret, and
* it has to be readable to be shown on a row you cannot open.
*/
val classification: Short,
val compartments: List<Int>,
val wrappedNodeKey: ByteArray,
val nameDigest: ByteArray,
val wrappedName: ByteArray,
val wrappedContentKey: ByteArray?,
val byteCount: Int,
val addedAtMillis: Long,
val storage: Storage = Storage.VAULT,
/** Set only for [Storage.REFERENCE]. */
val bookmark: String? = null,
) {
val label: Label get() = Label(classification, compartments.toSet())
// Hand-written because a data class compares ByteArray by identity, so
// the generated equals would call two identical rows different.
override fun equals(other: Any?): Boolean = other is Node && other.id == id
override fun hashCode(): Int = id.hashCode()
}
/** What a screen shows: the row, plus the name if this device can read it. */
data class Entry(
val node: Node,
val name: String?,
/**
* ⚠️ True when this is a reference whose file is no longer where it was.
* Surfaced rather than hidden.
*/
val isMissing: Boolean,
) {
val id: UUID get() = node.id
val label: Label get() = node.label
val isFolder: Boolean get() = node.isFolder
val byteCount: Int get() = node.byteCount
val storage: Storage get() = node.storage
}
/** What a bulk delete actually did. */
data class Removal(
val removed: List<UUID> = emptyList(),
/** Folders left alone because something inside them was not selected. */
val keptNonEmpty: List<UUID> = emptyList(),
) {
val isCompletelyDone: Boolean get() = keptNonEmpty.isEmpty()
}
class VaultException(message: String) : Exception(message)
private val lock = ReentrantLock()
private var nodes = mutableListOf<Node>()
private val indexFile: File get() = File(root, "index.json")
private val blobs: File get() = File(root, "blobs")
private fun blobFile(id: UUID) = File(blobs, id.toString())
// ---- Keys ------------------------------------------------------------
/**
* The key for a label, derived rather than fetched.
*
* 🔴 Deterministic from the identity key, so every device holding the
* identity derives the same one and a vault restored from a courier transfer
* opens without a grant round-trip. Sharing an area means sending this
* value, wrapped to the recipient — the same grant the server used to
* broker, minus the broker.
*
* ⚠️ **Binds the whole canonical label encoding, not just the
* classification.** Deriving from the level alone would give one key to
* every compartment at that level, which is precisely the separation
* compartments exist for.
*/
internal fun labelKey(label: Label): ByteArray = Hkdf.derive(
ikm = identityRawPrivate,
salt = label.bytes(),
info = FilesKdf.Context.LOCAL_LABEL.toByteArray(Charsets.UTF_8),
length = 32,
)
/**
* The name, or null when this device cannot read the row.
*
* ⚠️ On a local vault every label the identity can derive is readable, so
* this is non-null for anything this device filed itself. It exists for the
* case that is not that: a node that arrived from somebody else, filed under
* a label whose key was never granted. Those decrypt to null and the row
* says so.
*/
private fun nameOf(node: Node): String? = try {
val nodeKey = FileKeys.unwrapNodeKey(node.wrappedNodeKey, labelKey(node.label), node.label)
FileNames.decrypt(nodeKey, node.wrappedName, node.nameDigest, node.label)
} catch (e: Aead.DecryptionFailed) {
null
} catch (e: FilesEngineException) {
null
}
// ---- Storage ---------------------------------------------------------
fun load() = lock.withLock {
blobs.mkdirs()
val text = if (indexFile.exists()) indexFile.readText() else return@withLock
val root = JSONObject(text)
val array = root.optJSONArray("nodes") ?: JSONArray()
nodes = (0 until array.length()).mapTo(mutableListOf()) { decode(array.getJSONObject(it)) }
}
/**
* 🔴 Written to a temporary file and moved into place. An index truncated by
* a crash mid-write is a vault that has lost every file at once — and the
* blobs would still be sitting there, unreferenced, taking up the space.
*/
private fun save() {
root.mkdirs()
val temporary = File(root, "index.json.new")
temporary.writeText(
JSONObject()
.put("version", 1)
.put("nodes", JSONArray().also { array -> nodes.forEach { array.put(encode(it)) } })
.toString()
)
Files.move(
temporary.toPath(), indexFile.toPath(),
StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE,
)
}
private fun b64(bytes: ByteArray) = Base64.getEncoder().encodeToString(bytes)
private fun unb64(text: String) = Base64.getDecoder().decode(text)
private fun encode(node: Node): JSONObject = JSONObject()
.put("id", node.id.toString())
.put("parentID", node.parentId?.toString() ?: JSONObject.NULL)
.put("isFolder", node.isFolder)
.put("classification", node.classification.toInt())
.put("compartments", JSONArray(node.compartments))
.put("wrappedNodeKey", b64(node.wrappedNodeKey))
.put("nameDigest", b64(node.nameDigest))
.put("wrappedName", b64(node.wrappedName))
.put("wrappedContentKey", node.wrappedContentKey?.let { b64(it) } ?: JSONObject.NULL)
.put("byteCount", node.byteCount)
.put("addedAt", node.addedAtMillis)
.put("storage", node.storage.wire)
.put("bookmark", node.bookmark ?: JSONObject.NULL)
private fun decode(o: JSONObject): Node {
val bits = o.optJSONArray("compartments") ?: JSONArray()
return Node(
id = UUID.fromString(o.getString("id")),
parentId = o.optString("parentID", "").takeIf { it.isNotEmpty() }?.let(UUID::fromString),
isFolder = o.optBoolean("isFolder", false),
classification = o.getInt("classification").toShort(),
compartments = (0 until bits.length()).map { bits.getInt(it) },
wrappedNodeKey = unb64(o.getString("wrappedNodeKey")),
nameDigest = unb64(o.getString("nameDigest")),
wrappedName = unb64(o.getString("wrappedName")),
wrappedContentKey = o.optString("wrappedContentKey", "")
.takeIf { it.isNotEmpty() }?.let(::unb64),
byteCount = o.optInt("byteCount", 0),
addedAtMillis = o.optLong("addedAt", 0L),
storage = Storage.parse(o.optString("storage", "").takeIf { it.isNotEmpty() }),
bookmark = o.optString("bookmark", "").takeIf { it.isNotEmpty() },
)
}
// ---- Reading ---------------------------------------------------------
fun list(parentId: UUID?): List<Entry> = lock.withLock {
nodes.filter { it.parentId == parentId }
.map { Entry(it, nameOf(it), isMissing = isMissing(it)) }
.sortedWith(
// Folders first, then by name; unreadable rows last, where they
// are least in the way of the ones you can act on.
compareByDescending<Entry> { it.isFolder }
.thenBy { it.name == null }
.thenBy(String.CASE_INSENSITIVE_ORDER) { it.name ?: "" }
)
}
private fun isMissing(node: Node): Boolean =
node.storage == Storage.REFERENCE &&
(node.bookmark == null || !references.exists(node.bookmark))
fun node(id: UUID): Node? = lock.withLock { nodes.firstOrNull { it.id == id } }
val count: Int get() = lock.withLock { nodes.size }
/**
* Every id in a subtree, so a screen can offer "and everything in it".
*
* 🔴 **Bounded by what it has already seen, not by a step counter.** A cycle
* cannot be built through this API, but the index is a file on disk and
* something else could write one — so this has to terminate. iOS bounds it
* with `guardRail < 10_000`, which terminates and then hands back ten
* thousand copies of the same two ids: a screen offering to delete a folder
* "and everything in it" would say 10,000 things. Refusing to revisit a node
* both terminates AND returns the right answer, and needs no magic number.
*/
fun descendants(of: UUID): List<UUID> = lock.withLock {
val found = mutableListOf<UUID>()
val seen = mutableSetOf(of)
val frontier = ArrayDeque(listOf(of))
while (frontier.isNotEmpty()) {
val next = frontier.removeLast()
for (child in nodes.filter { it.parentId == next }) {
if (!seen.add(child.id)) continue
found += child.id
frontier.addLast(child.id)
}
}
found
}
fun contents(of: Node): ByteArray = lock.withLock { readContents(of) }
private fun readContents(node: Node): ByteArray {
if (node.isFolder) throw VaultException("That is a folder.")
if (node.storage == Storage.REFERENCE) {
val bookmark = node.bookmark
if (bookmark == null || !references.exists(bookmark)) {
throw VaultException(
"That file isn't where it was — it may have been moved or deleted.")
}
return references.read(bookmark)
}
val wrappedContentKey = node.wrappedContentKey
?: throw VaultException("That file has no contents.")
val nodeKey = FileKeys.unwrapNodeKey(node.wrappedNodeKey, labelKey(node.label), node.label)
val contentKey = FileKeys.unwrapContentKey(wrappedContentKey, nodeKey, node.label)
return FileContent.decrypt(blobFile(node.id).readBytes(), contentKey, node.label)
}
/**
* The bytes, encrypted for sending — whichever way they are stored.
*
* 🔴 **This is where a referenced file first becomes ciphertext.** It is
* sealed under a fresh content key bound to the label, so the courier
* carries something it cannot read and the classification finally gates
* something. Returns the sealed blob and the key to hand the recipient.
*/
data class Sealed(val blob: ByteArray, val contentKey: ByteArray)
fun sealedForSending(node: Node): Sealed = lock.withLock {
val plaintext = readContents(node)
val contentKey = Random.bytes(32)
Sealed(FileContent.encrypt(plaintext, contentKey, node.label), contentKey)
}
// ---- Writing ---------------------------------------------------------
fun add(bytes: ByteArray, name: String, label: Label, parentId: UUID? = null): Node =
lock.withLock {
val node = makeNode(name, label, parentId, isFolder = false, byteCount = bytes.size)
val nodeKey = FileKeys.unwrapNodeKey(node.wrappedNodeKey, labelKey(label), label)
val contentKey = Random.bytes(32)
// 🔴 Bytes to disk BEFORE the index. The other order leaves an index
// entry for a file that is not there, which reads as corruption;
// this order leaves at worst an unreferenced blob, which is a
// cleanup job nobody notices.
blobs.mkdirs()
writeAtomically(blobFile(node.id), FileContent.encrypt(bytes, contentKey, label))
val stored = node.copy(
wrappedContentKey = FileKeys.wrapContentKey(contentKey, nodeKey, label))
nodes += stored
save()
stored
}
fun createFolder(name: String, label: Label, parentId: UUID? = null): Node = lock.withLock {
val node = makeNode(name, label, parentId, isFolder = true, byteCount = 0)
nodes += node
save()
node
}
/**
* File a document that stays where it is.
*
* 🔴 Nothing is copied and nothing is encrypted here — see [Storage]. The
* bytes get their content key at send time, not now, so until then this
* row's label is a statement of intent rather than a lock.
*/
fun reference(bookmark: String, name: String, label: Label, parentId: UUID? = null): Node =
lock.withLock {
val node = makeNode(
name, label, parentId, isFolder = false,
byteCount = references.size(bookmark),
).copy(storage = Storage.REFERENCE, bookmark = bookmark)
nodes += node
save()
node
}
/**
* Turn a reference into an encrypted copy, in place.
*
* 🔴 **The bridge between the two ways of holding a file, and the reason
* both exist.** Referencing avoids duplicating anything; vaulting is what
* makes a classification a control rather than a note. Most files want the
* first and a few want the second, and nobody knows which is which until
* after they have added them — so this promotes one without re-picking it.
*
* ⚠️ It does NOT delete the person's original: that is their file, and
* removing it is a separate decision the interface asks about afterwards.
* Until they do, this genuinely is two copies — the honest trade for having
* the encrypted one.
*
* Returns the bookmark the original was read from, so a caller can offer
* that, or null when the row was already vaulted.
*/
fun encrypt(id: UUID): String? = lock.withLock {
val position = nodes.indexOfFirst { it.id == id }
if (position < 0) throw VaultException("That file isn't here any more.")
val node = nodes[position]
if (node.storage != Storage.REFERENCE) return@withLock null
val origin = node.bookmark
if (origin == null || !references.exists(origin)) {
throw VaultException("That file isn't where it was — it may have been moved or deleted.")
}
val plaintext = readContents(node)
val nodeKey = FileKeys.unwrapNodeKey(node.wrappedNodeKey, labelKey(node.label), node.label)
val contentKey = Random.bytes(32)
// Bytes before index, as everywhere else here.
blobs.mkdirs()
writeAtomically(blobFile(node.id), FileContent.encrypt(plaintext, contentKey, node.label))
nodes[position] = node.copy(
storage = Storage.VAULT,
// 🔴 The bookmark goes. Keeping it would leave a vaulted row still
// able to read through to a plaintext original, so the encrypted
// copy would not be the only way in — which is the entire point of
// vaulting it.
bookmark = null,
wrappedContentKey = FileKeys.wrapContentKey(contentKey, nodeKey, node.label),
byteCount = plaintext.size,
)
save()
origin
}
private fun makeNode(
name: String,
label: Label,
parentId: UUID?,
isFolder: Boolean,
byteCount: Int,
): Node {
val nodeKey = Random.bytes(32)
val sealed = FileNames.encrypt(nodeKey, name, label)
return Node(
id = UUID.randomUUID(),
parentId = parentId,
isFolder = isFolder,
classification = label.classification,
compartments = label.compartments.sorted(),
// 🔴 Wrapped to the node's OWN label key, never the parent's. The
// tree allows a Restricted file inside a Confidential folder, so a
// folder-derived key would hand every folder-key holder content
// above their clearance.
wrappedNodeKey = FileKeys.wrapNodeKey(nodeKey, labelKey(label), label),
nameDigest = sealed.digest,
wrappedName = sealed.ciphertext,
wrappedContentKey = null,
byteCount = byteCount,
addedAtMillis = System.currentTimeMillis(),
)
}
private fun writeAtomically(target: File, bytes: ByteArray) {
val temporary = File(target.parentFile, target.name + ".new")
temporary.writeBytes(bytes)
Files.move(
temporary.toPath(), target.toPath(),
StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE,
)
}
// ---- Deleting --------------------------------------------------------
/**
* Removes a node and its bytes.
*
* ⚠️ Refuses a folder that still has anything in it. Deleting a folder and
* orphaning its contents would leave rows nothing can reach and bytes
* nothing will ever free.
*/
fun remove(id: UUID) = lock.withLock {
val position = nodes.indexOfFirst { it.id == id }
if (position < 0) return@withLock
val node = nodes[position]
if (node.isFolder && nodes.any { it.parentId == id }) {
throw VaultException("That folder isn't empty.")
}
nodes.removeAt(position)
save()
// Never the person's own file — see the note in the bulk version.
if (node.storage == Storage.VAULT) blobFile(id).delete()
}
/**
* Delete several things at once.
*
* 🔴 **This reports rather than returning nothing.** Selecting eleven things
* and having nine disappear is a data-loss-shaped event in the other
* direction: the two that survived are the ones somebody now thinks are
* gone. The screen says which, and why.
*
* ⚠️ Deletes deepest-first, so selecting a folder AND everything in it
* works. A folder whose contents were NOT all selected is kept, because the
* alternative is a bulk gesture that silently takes rows the person never
* selected — including ones filed above what this device can even read,
* which they could not have reviewed before tapping.
*/
fun remove(ids: List<UUID>): Removal = lock.withLock {
val removed = mutableListOf<UUID>()
val keptNonEmpty = mutableListOf<UUID>()
val removedVaultIds = mutableListOf<UUID>()
val wanted = ids.toSet()
// Depth first: a child must go before the parent that contains it.
fun depth(id: UUID): Int {
var steps = 0
var current = nodes.firstOrNull { it.id == id }?.parentId
while (current != null && steps < 64) {
steps++
current = nodes.firstOrNull { it.id == current }?.parentId
}
return steps
}
val ordered = ids.filter { id -> nodes.any { it.id == id } }
.sortedByDescending { depth(it) }
for (id in ordered) {
val position = nodes.indexOfFirst { it.id == id }
if (position < 0) continue
val node = nodes[position]
if (node.isFolder) {
// Anything left inside that was not itself selected?
if (nodes.any { it.parentId == id && it.id !in wanted }) {
keptNonEmpty += id
continue
}
}
if (node.storage == Storage.VAULT) removedVaultIds += id
nodes.removeAt(position)
removed += id
}
// 🔴 One save for the whole batch, after the index is consistent. A save
// per row would leave a half-deleted tree on disk if the process died
// mid-loop; the blobs go afterwards, so a crash costs orphaned bytes
// rather than rows pointing at files that are gone.
save()
// 🔴 Only ever our own blobs. A referenced row's bytes are the person's
// own file, sitting where they put it — deleting the row must not delete
// their document, and `blobFile` for a reference names a file that was
// never written.
removedVaultIds.forEach { blobFile(it).delete() }
Removal(removed, keptNonEmpty)
}
}
@@ -0,0 +1,513 @@
package io.flamenet.client
import io.flamenet.e2e.Aead
import io.flamenet.e2e.FileContent
import io.flamenet.e2e.FileKeys
import io.flamenet.e2e.Label
import java.io.File
import java.util.UUID
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import org.json.JSONObject
/**
* The local vault.
*
* ⚠️ Against a real directory in a temporary folder, not an in-memory fake. Half
* of what this class does IS the filesystem — atomic replaces, blobs written
* before rows, blobs that must survive a row being removed — and a fake would
* agree with the implementation about all of it.
*/
class LocalVaultTest {
/**
* Files somebody else owns, that we only point at.
*
* Records every call, because for the delete path "the file survived" is not
* a claim worth making on its own — see the test that uses [calls].
*/
private class FakeReferences : LocalVault.ReferenceResolver {
val files = mutableMapOf<String, ByteArray>()
val calls = mutableListOf<String>()
override fun exists(bookmark: String): Boolean {
calls += "exists($bookmark)"
return files.containsKey(bookmark)
}
override fun read(bookmark: String): ByteArray {
calls += "read($bookmark)"
return files[bookmark] ?: throw IllegalStateException("gone")
}
override fun size(bookmark: String): Int {
calls += "size($bookmark)"
return files[bookmark]?.size ?: 0
}
}
private lateinit var root: File
private lateinit var refs: FakeReferences
private lateinit var vault: LocalVault
/** A stable identity, so a reopened vault derives the same label keys. */
private val identity = ByteArray(32) { (it * 3 + 1).toByte() }
private val secret = Label(5, setOf(1))
private val open = Label(1)
@BeforeTest
fun setUp() {
root = File.createTempFile("vault", "").let { it.delete(); it.mkdirs(); it }
refs = FakeReferences()
vault = LocalVault(root, identity, refs).also { it.load() }
}
@AfterTest
fun tearDown() { root.deleteRecursively() }
private fun reopen(): LocalVault = LocalVault(root, identity, refs).also { it.load() }
private fun hex(b: ByteArray) = b.joinToString("") { "%02x".format(it) }
private fun blobsOnDisk() = File(root, "blobs").listFiles()?.map { it.name }?.toSet() ?: emptySet()
// ---- The round trip --------------------------------------------------
@Test
fun `a file goes in and comes back out`() {
val bytes = "revenue: 41\nheadcount: 12\n".toByteArray()
val node = vault.add(bytes, "Q3 Forecast.txt", secret)
val entry = vault.list(null).single()
assertEquals("Q3 Forecast.txt", entry.name)
assertEquals(bytes.size, entry.byteCount)
assertEquals(LocalVault.Storage.VAULT, entry.storage)
assertEquals(hex(bytes), hex(vault.contents(node)))
}
/**
* 🔴 **The plaintext must not be on disk anywhere.** The whole claim of a
* vault is that a stolen container yields ciphertext, and a search for the
* bytes is the only way to be sure — a round trip passes just as happily if
* `add` wrote the file out unencrypted and read it straight back.
*/
@Test
fun `nothing on disk contains the plaintext`() {
val phrase = "SEVERANCE-AGREEMENT-DRAFT"
vault.add("$phrase and some more text".toByteArray(), "Payroll.txt", secret)
val needle = phrase.toByteArray()
for (file in root.walkTopDown().filter { it.isFile }) {
val haystack = file.readBytes()
val found = (0..(haystack.size - needle.size)).any { at ->
needle.indices.all { haystack[at + it] == needle[it] }
}
assertFalse(found, "${file.name} holds the plaintext")
}
// ...and the name is not sitting in the index either.
assertFalse(File(root, "index.json").readText().contains("Payroll"),
"the filename is stored in the clear")
}
@Test
fun `a vault survives being closed and reopened`() {
val bytes = ByteArray(200_000) { (it % 251).toByte() }
val node = vault.add(bytes, "big.bin", secret)
vault.createFolder("Reports", open)
val second = reopen()
assertEquals(2, second.count)
assertEquals(hex(bytes), hex(second.contents(assertNotNull(second.node(node.id)))))
assertEquals(setOf("Reports", "big.bin"), second.list(null).map { it.name }.toSet())
}
// ---- The index -------------------------------------------------------
/**
* 🔴 **An index with no `storage` field must read as vaulted.** iOS shipped
* this field with a Swift default and found out that a default does not make
* a `Codable` key optional: every index written before referencing existed
* failed to decode, which presents to a person as a vault that has lost
* everything. The field is stripped here on purpose, from a real index.
*/
@Test
fun `an index written before storage existed still loads, as vaulted`() {
val bytes = "old file".toByteArray()
val node = vault.add(bytes, "legacy.txt", secret)
val index = File(root, "index.json")
val json = JSONObject(index.readText())
json.getJSONArray("nodes").getJSONObject(0).remove("storage")
index.writeText(json.toString())
val second = reopen()
assertEquals(1, second.count)
assertEquals(LocalVault.Storage.VAULT, assertNotNull(second.node(node.id)).storage)
assertEquals(hex(bytes), hex(second.contents(assertNotNull(second.node(node.id)))))
}
/** An unknown value is vaulted too, rather than throwing the row away. */
@Test
fun `an unrecognised storage value reads as vaulted`() {
vault.add("x".toByteArray(), "f.txt", secret)
val index = File(root, "index.json")
val json = JSONObject(index.readText())
json.getJSONArray("nodes").getJSONObject(0).put("storage", "something-newer")
index.writeText(json.toString())
assertEquals(LocalVault.Storage.VAULT, reopen().list(null).single().storage)
}
@Test
fun `an empty directory loads as an empty vault rather than failing`() {
val fresh = File(root, "elsewhere").also { it.mkdirs() }
assertEquals(0, LocalVault(fresh, identity, refs).also { it.load() }.count)
}
/**
* ⚠️ The temporary file must not survive a save. One left behind is
* harmless today and is exactly what a future "load the newest index"
* shortcut would pick up.
*/
@Test
fun `saving leaves no temporary index behind`() {
vault.add("x".toByteArray(), "f.txt", secret)
assertFalse(File(root, "index.json.new").exists())
assertTrue(File(root, "index.json").exists())
}
// ---- Labels ----------------------------------------------------------
/**
* 🔴 **The label key binds the WHOLE canonical encoding.** Deriving from the
* classification alone would hand one key to every compartment at that
* level — which is the exact separation compartments exist for, and it would
* pass every test built from levels alone.
*/
@Test
fun `two labels at one level but different compartments derive different keys`() {
val a = vault.labelKey(Label(5, setOf(1)))
val b = vault.labelKey(Label(5, setOf(2)))
val bare = vault.labelKey(Label(5))
assertEquals(3, setOf(hex(a), hex(b), hex(bare)).size,
"compartments are not reaching the key derivation")
}
/**
* 🔴 **A node key is wrapped to its OWN label, never its parent's.** The tree
* deliberately allows a low-classification file inside a high one, so a
* folder-derived key would hand every holder of the folder's key content
* filed above them.
*/
@Test
fun `a file's key does not come from the folder above it`() {
val folder = vault.createFolder("Confidential", secret)
val file = vault.add("inside".toByteArray(), "note.txt", open, parentId = folder.id)
// The folder's own label key must not unwrap the file's node key.
assertFailsWith<Aead.DecryptionFailed> {
FileKeys.unwrapNodeKey(file.wrappedNodeKey, vault.labelKey(secret), file.label)
}
// ...while the file's own does.
assertEquals("note.txt", vault.list(folder.id).single().name)
}
/**
* A row filed by somebody else, under a label whose key was never granted:
* unreadable, but still LISTED, with its classification visible. A row that
* simply vanished would leave a person unable to see that anything is there.
*/
@Test
fun `a row this device cannot read is listed without its name`() {
vault.add("mine".toByteArray(), "readable.txt", secret)
val strangerVault = LocalVault(root, ByteArray(32) { 99 }, refs).also { it.load() }
val entry = strangerVault.list(null).single()
assertNull(entry.name, "a row wrapped under a key we do not hold decrypted anyway")
assertEquals(secret, entry.label, "the classification must stay visible")
assertFailsWith<Aead.DecryptionFailed> { strangerVault.contents(entry.node) }
}
@Test
fun `unreadable rows sort last, and folders first`() {
vault.createFolder("Zebra", open)
vault.add("a".toByteArray(), "apple.txt", open)
vault.add("b".toByteArray(), "banana.txt", open)
val stranger = LocalVault(root, ByteArray(32) { 99 }, refs).also { it.load() }
// Everything is unreadable to the stranger, so file one readable row.
val names = vault.list(null).map { it.name }
assertEquals(listOf("Zebra", "apple.txt", "banana.txt"), names)
assertEquals(3, stranger.list(null).size)
assertTrue(stranger.list(null).all { it.name == null })
}
// ---- Sending ---------------------------------------------------------
@Test
fun `a sealed file opens with the key that was handed over`() {
val bytes = "for you".toByteArray()
val node = vault.add(bytes, "gift.txt", secret)
val sealed = vault.sealedForSending(node)
assertEquals(hex(bytes), hex(FileContent.decrypt(sealed.blob, sealed.contentKey, secret)))
}
/**
* 🔴 A fresh content key every time. Reusing the stored one would mean two
* recipients of one file hold the same key — so revoking either is
* impossible, and the vault's own blob opens with a key that has left the
* device.
*/
@Test
fun `sealing twice uses two different keys`() {
val node = vault.add("twice".toByteArray(), "f.txt", secret)
val a = vault.sealedForSending(node)
val b = vault.sealedForSending(node)
assertFalse(a.contentKey.contentEquals(b.contentKey), "the content key is being reused")
assertFailsWith<Aead.DecryptionFailed> { FileContent.decrypt(a.blob, b.contentKey, secret) }
}
/** The label travels with the file: it will not open filed as anything else. */
@Test
fun `a sealed file does not open under a different label`() {
val node = vault.add("x".toByteArray(), "f.txt", secret)
val sealed = vault.sealedForSending(node)
assertFailsWith<Aead.DecryptionFailed> {
FileContent.decrypt(sealed.blob, sealed.contentKey, open)
}
}
// ---- References ------------------------------------------------------
@Test
fun `a referenced file is read through without being copied`() {
refs.files["doc://1"] = "their document".toByteArray()
val node = vault.reference("doc://1", "Their doc.pdf", open)
assertEquals(LocalVault.Storage.REFERENCE, node.storage)
assertEquals("their document".length, node.byteCount)
assertEquals(hex("their document".toByteArray()), hex(vault.contents(node)))
assertTrue(blobsOnDisk().isEmpty(), "a reference wrote a blob — it is meant to copy nothing")
}
/**
* ⚠️ A file the person moved or deleted is not an error. It is their file.
* The row reports itself missing and they decide what to do — a row that
* silently does nothing when tapped is worse than one that says so.
*/
@Test
fun `a reference whose file has gone reports itself missing`() {
refs.files["doc://1"] = "here for now".toByteArray()
val node = vault.reference("doc://1", "Their doc.pdf", open)
assertFalse(vault.list(null).single().isMissing)
refs.files.remove("doc://1")
assertTrue(vault.list(null).single().isMissing)
assertEquals("Their doc.pdf", vault.list(null).single().name,
"the row lost its name as well as its file")
assertFailsWith<LocalVault.VaultException> { vault.contents(node) }
}
/**
* 🔴 **Promoting to the vault drops the bookmark.** Keeping it would leave a
* vaulted row still able to read through to a plaintext original, so the
* encrypted copy would not be the only way in — which is the entire point of
* vaulting it.
*/
@Test
fun `promoting a reference encrypts it and lets go of the original`() {
refs.files["doc://1"] = "promote me".toByteArray()
val node = vault.reference("doc://1", "doc.pdf", secret)
val origin = vault.encrypt(node.id)
assertEquals("doc://1", origin, "the caller was not told which file to offer to delete")
val after = assertNotNull(vault.node(node.id))
assertEquals(LocalVault.Storage.VAULT, after.storage)
assertNull(after.bookmark, "a vaulted row can still read through to the plaintext original")
assertEquals(setOf(node.id.toString()), blobsOnDisk())
// ⚠️ And the person's own file is still theirs.
assertTrue(refs.files.containsKey("doc://1"), "promoting deleted somebody's own document")
assertEquals(hex("promote me".toByteArray()), hex(vault.contents(after)))
}
@Test
fun `promoting something already vaulted does nothing`() {
val node = vault.add("x".toByteArray(), "f.txt", secret)
assertNull(vault.encrypt(node.id))
}
@Test
fun `promoting a reference whose file has gone says so`() {
refs.files["doc://1"] = "x".toByteArray()
val node = vault.reference("doc://1", "doc.pdf", secret)
refs.files.remove("doc://1")
assertFailsWith<LocalVault.VaultException> { vault.encrypt(node.id) }
}
// ---- Deleting --------------------------------------------------------
@Test
fun `deleting a file takes its bytes with it`() {
val node = vault.add("x".toByteArray(), "f.txt", secret)
assertEquals(setOf(node.id.toString()), blobsOnDisk())
vault.remove(node.id)
assertEquals(0, vault.count)
assertTrue(blobsOnDisk().isEmpty(), "the blob outlived its row")
}
/**
* 🔴 **Deleting a REFERENCE row must not touch the person's document.**
* Those bytes are their file, sitting where they put it. This is the one
* mistake in the whole class that cannot be undone.
*
* ⚠️ **Asserting only that the file survived would be a test that cannot
* fail**, because the delete path does not hold a resolver call to remove —
* the protection is structural: [LocalVault.ReferenceResolver] has no way to
* delete anything. So this asserts the property that actually constrains
* future code: removing a row reaches the person's storage **not at all**.
* Anybody who later routes the delete path through the resolver has to come
* here and say why.
*/
@Test
fun `deleting a reference row does not reach the person's storage at all`() {
refs.files["doc://1"] = "still mine".toByteArray()
val node = vault.reference("doc://1", "doc.pdf", open)
refs.calls.clear()
vault.remove(node.id)
assertEquals(emptyList(), refs.calls,
"the delete path is talking to the person's own storage")
assertTrue(refs.files.containsKey("doc://1"), "removing a row deleted somebody's document")
// Same again for the bulk path, which is the one a screen actually uses.
val second = vault.reference("doc://1", "doc.pdf", open)
refs.calls.clear()
vault.remove(listOf(second.id))
assertEquals(emptyList(), refs.calls,
"the bulk delete path is talking to the person's own storage")
assertTrue(refs.files.containsKey("doc://1"))
}
@Test
fun `a folder with something in it is not deleted`() {
val folder = vault.createFolder("Reports", open)
vault.add("x".toByteArray(), "inside.txt", open, parentId = folder.id)
assertFailsWith<LocalVault.VaultException> { vault.remove(folder.id) }
assertEquals(2, vault.count)
}
/**
* ⚠️ Deepest-first, so selecting a folder AND everything in it works. Doing
* it in the order given would refuse the folder for being non-empty and
* leave half the selection behind.
*/
@Test
fun `selecting a folder and its contents removes all of it`() {
val folder = vault.createFolder("Reports", open)
val inner = vault.createFolder("2026", open, parentId = folder.id)
val file = vault.add("x".toByteArray(), "q3.txt", open, parentId = inner.id)
// Deliberately shallowest-first, which is the order a screen produces.
val result = vault.remove(listOf(folder.id, inner.id, file.id))
assertTrue(result.isCompletelyDone)
assertEquals(3, result.removed.size)
assertEquals(0, vault.count)
assertTrue(blobsOnDisk().isEmpty())
}
/**
* 🔴 **The report is the point.** Selecting eleven things and having nine go
* is a data-loss-shaped event in the other direction: the two that survived
* are the ones somebody now believes are gone.
*/
@Test
fun `a folder whose contents were not all selected is kept, and reported`() {
val folder = vault.createFolder("Reports", open)
val chosen = vault.add("a".toByteArray(), "chosen.txt", open, parentId = folder.id)
vault.add("b".toByteArray(), "not chosen.txt", open, parentId = folder.id)
val result = vault.remove(listOf(folder.id, chosen.id))
assertFalse(result.isCompletelyDone, "the screen would have said everything went")
assertEquals(listOf(folder.id), result.keptNonEmpty)
assertEquals(listOf(chosen.id), result.removed)
assertEquals(2, vault.count)
}
/**
* ⚠️ A row filed above what this device can read still counts as contents.
* Otherwise a bulk gesture would take rows the person could not have
* reviewed before tapping.
*/
@Test
fun `an unreadable row inside a folder still protects it from a bulk delete`() {
val folder = vault.createFolder("Shared", open)
vault.add("theirs".toByteArray(), "theirs.txt", secret, parentId = folder.id)
val stranger = LocalVault(root, ByteArray(32) { 99 }, refs).also { it.load() }
val result = stranger.remove(listOf(folder.id))
assertEquals(listOf(folder.id), result.keptNonEmpty)
assertEquals(2, stranger.count)
}
@Test
fun `deleting something that is not there is not an error`() {
vault.remove(UUID.randomUUID())
assertEquals(LocalVault.Removal(), vault.remove(listOf(UUID.randomUUID())))
}
/** The removals reach disk, not just memory. */
@Test
fun `a delete survives a reopen`() {
val a = vault.add("a".toByteArray(), "a.txt", open)
vault.add("b".toByteArray(), "b.txt", open)
vault.remove(listOf(a.id))
assertEquals(listOf("b.txt"), reopen().list(null).map { it.name })
}
// ---- The tree --------------------------------------------------------
@Test
fun `descendants walks the whole subtree`() {
val folder = vault.createFolder("Reports", open)
val inner = vault.createFolder("2026", open, parentId = folder.id)
val file = vault.add("x".toByteArray(), "q3.txt", open, parentId = inner.id)
vault.add("elsewhere".toByteArray(), "other.txt", open)
assertEquals(setOf(inner.id, file.id), vault.descendants(folder.id).toSet())
assertTrue(vault.descendants(file.id).isEmpty())
}
/**
* ⚠️ A cycle cannot happen through this API, and the guard is here because
* an index is a file on disk that something else could write. Without it
* this is an infinite loop in a method a screen calls.
*/
@Test
fun `a cycle in the index does not hang the tree walk`() {
val a = vault.createFolder("A", open)
val b = vault.createFolder("B", open, parentId = a.id)
val index = File(root, "index.json")
val json = JSONObject(index.readText())
val array = json.getJSONArray("nodes")
for (i in 0 until array.length()) {
val o = array.getJSONObject(i)
if (o.getString("id") == a.id.toString()) o.put("parentID", b.id.toString())
}
index.writeText(json.toString())
val cyclic = reopen()
// 🔴 Not merely "it terminated": each node once. A step-counter guard
// would end the loop and hand a screen ten thousand copies of two ids,
// which is what "delete this folder and everything in it" would then
// offer to do.
assertEquals(listOf(b.id), cyclic.descendants(a.id))
assertEquals(listOf(a.id), cyclic.descendants(b.id))
// And a bulk delete over it terminates rather than spinning in `depth`.
cyclic.remove(listOf(a.id, b.id))
}
}