diff --git a/PARITY.md b/PARITY.md index 71181b5..29f8ed0 100644 --- a/PARITY.md +++ b/PARITY.md @@ -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 | diff --git a/client/src/main/kotlin/io/flamenet/client/LocalVault.kt b/client/src/main/kotlin/io/flamenet/client/LocalVault.kt new file mode 100644 index 0000000..09fb810 --- /dev/null +++ b/client/src/main/kotlin/io/flamenet/client/LocalVault.kt @@ -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, + 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 = emptyList(), + /** Folders left alone because something inside them was not selected. */ + val keptNonEmpty: List = emptyList(), + ) { + val isCompletelyDone: Boolean get() = keptNonEmpty.isEmpty() + } + + class VaultException(message: String) : Exception(message) + + private val lock = ReentrantLock() + private var nodes = mutableListOf() + + 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 = 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 { 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 = lock.withLock { + val found = mutableListOf() + 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): Removal = lock.withLock { + val removed = mutableListOf() + val keptNonEmpty = mutableListOf() + val removedVaultIds = mutableListOf() + 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) + } +} diff --git a/client/src/test/kotlin/io/flamenet/client/LocalVaultTest.kt b/client/src/test/kotlin/io/flamenet/client/LocalVaultTest.kt new file mode 100644 index 0000000..c6f151b --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/LocalVaultTest.kt @@ -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() + val calls = mutableListOf() + 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 { + 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 { 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 { 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 { + 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 { 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 { 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 { 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)) + } +}