A live test for the file round trip, and four published passwords #85

Merged
paulhitt merged 2 commits from test/live-file-transfer into main 2026-09-04 20:50:19 +00:00
7 changed files with 342 additions and 20 deletions
+31 -3
View File
@@ -113,7 +113,7 @@ From `E2E/E2EInbox.swift`, which is the real feature list.
| Trust state, verification, identity-change acceptance | ✅ pinned in the vault, surfaced on the thread |
| Key-log enforcement and head gossip | ✅ opt-in enforcement, gossip detects a fork |
| Clear thread | ✅ keeps the session, so the chat still works |
| History at rest (encrypted, device-only key) | ⚪ |
| History at rest (encrypted, device-only key) | ✅ **this row was stale** — sealed per thread under a Keystore key, read straight from disk; see below |
| Presence (read and set) | ✅ shown from the relay, never inferred |
| Buddy list, add by username | ✅ |
| Remove, block, unblock | ✅ |
@@ -121,7 +121,35 @@ From `E2E/E2EInbox.swift`, which is the real feature list.
| Buddy commands | — retired; not a feature any more (owner, 2026-08-25) |
| Voice calls (WebRTC, DTLS-SRTP, TURN) | ✅ P2P-first; ⚠️ no TURN deployed |
| Notifications list, read state | ✅ |
| Services (AIM/ICQ via `flamenet-oscar`) | ⚪ |
| Services (AIM/ICQ via `flamenet-oscar`) | — retired; not a goal for this app (owner, 2026-09-04) |
🔴 **History at rest was marked ⚪ and it has been built the whole time — the
third stale row in this file.** Every read goes to disk: `Session.history` calls
`Vault.history`, which decrypts the record. There is no in-memory copy to be
fooled by.
What Android does, against what iOS's `E2EHistoryStore` does:
| | iOS | Android |
|---|---|---|
| Cipher | AES-256-GCM | AES-256-GCM |
| Key | Keychain, `ThisDeviceOnly` | Android Keystore — never leaves the process at all |
| Bound to its slot | one file, nothing to confuse | 🟢 **the record NAME is the AAD**, so a blob cannot be moved between slots |
| Shape | one snapshot for everything | one record per thread |
| Off backups | excluded from iCloud | `allowBackup="false"`, and a Keystore key is unbackupable anyway |
| At rest, locked | `.completeFileProtection` | Credential-Encrypted storage: `filesDir` is unreadable until the first unlock after boot |
| Plain sign-out | keeps history | keeps history |
| Erase this device | `eraseLocalData()` | `forgetEverything()` **and** the Keystore key is deleted |
⚠️ Two honest differences rather than a claim of equivalence. Android's
per-record AAD is a binding iOS's single file does not need; iOS's
`.completeFileProtection` is a per-file assertion, while Android's equivalent is
a property of the storage volume rather than of this app's request. Neither gap
favours one platform overall.
⚠️ `Vault.history` prunes expired messages on the way out, so disappearing
messages are enforced on READ rather than by a timer nothing runs. It rewrites
the record only when something actually expired — a read is not a write.
✅ Calls are built, on `io.github.webrtc-sdk:android` — the one third-party
library in this app, and unavoidable: the alternative is hand-rolling ICE,
@@ -341,7 +369,7 @@ the messenger rather than staying separate apps.
| Local vault: add, organise, delete | ✅ index, folders, both storage modes, bulk delete with its report |
| Files screen: set up, unlock, browse, add, folders, delete, promote | ✅ **verified on a device** — set up, encrypt, lock, cold-start reload, link via SAF |
| The security lattice (labels, clearance) | ✅ encoding and clearance in Kotlin, pinned to independently-computed vectors. ⚠️ **Not Bell–LaPadula** — the ★-property is deliberately unenforced on both clients; see android#15 |
| Share a file into a chat or group | ✅ both directions — send from the vault, save a received one back at the label it arrived with |
| Share a file into a chat or group | ✅ both directions; `LiveFileTransferTest` proves the label survives a real relay ⚠️ **unrun** |
| Wallet engine: BIP-39, Base58, SLIP-0010 | ✅ pinned to the published vectors |
| Wallet engine: address derivation and signing | ✅ BIP-44 path, Base58 address, and the engine's own RFC 8032 signs — no API-33 floor |
| Wallet: BIP-39 seed, create and restore | ✅ create, import, restore, sealed at rest, with the phrase shown once and explained |
@@ -37,6 +37,16 @@ class LiveConversationTest {
private fun needFnLiveFleet() {
LiveTarget.refuseProduction(fleet)
assumeTrue(fleet != null, "set FN_LIVE_FLEET to run this against a relay")
// 🔴 The passphrase comes from the environment with no fallback. This
// file carried a hard-coded literal — a published password, used to
// create REAL accounts — and `TestSafetyTest` could
// not see it, because its check matched an ASSIGNMENT and this was an
// ARGUMENT. Both are fixed together.
assumeTrue(
LiveTarget.passphrase().isNotEmpty(),
"set FN_LIVE_PASSPHRASE — these tests create real accounts and this " +
"suite will not sign up with a value written into the source",
)
}
/** A signed-up account with a registered device, ready to talk. */
@@ -62,7 +72,8 @@ class LiveConversationTest {
private fun signUp(base: String, tag: String, preKeys: Int = 20): Person {
val handle = "live$tag" + (System.nanoTime() % 10_000_000)
val account = RelayAccounts(base).signUp(handle, "$handle@example.invalid", "a-long-enough-passphrase")
val account = RelayAccounts(base)
.signUp(handle, "$handle@example.invalid", LiveTarget.passphrase())
val identity = DeviceIdentity.create()
val vault = Vault(MapStorage(), key())
@@ -311,6 +322,6 @@ class LiveConversationTest {
private fun appPasswordOf(person: Person): String =
appPasswords.getOrPut(person.handle) {
RelayAccounts(fleet!!).signIn(person.handle, "a-long-enough-passphrase").appPassword
RelayAccounts(fleet!!).signIn(person.handle, LiveTarget.passphrase()).appPassword
}
}
@@ -0,0 +1,221 @@
package io.flamenet.client
import io.flamenet.e2e.Attachment
import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.FilesIdentity
import io.flamenet.e2e.Label
import io.flamenet.e2e.Vault
import java.io.File
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import org.junit.jupiter.api.Assumptions.assumeTrue
/**
* A document leaves one person's vault and arrives in another's, through a real
* relay.
*
* 🔴 **This is the seam nothing else covers.** `FileShareWireTest` proves the
* envelope's shape, `LocalVaultTest` proves the vault at both ends, and neither
* of them puts a byte on a wire. What is only provable here is that the label
* survives an actual upload, an actual envelope, an actual poll and an actual
* save — the four steps between two vaults that each look right on their own.
*
* ⚠️ **The classification is the only thing that survives the trip.** The
* receiving phone files the document at whatever number arrives, because there
* is nothing else to consult and no server to ask. A label lost in transit does
* not error anywhere: the file simply arrives Unclassified and gets filed that
* way, on the most permissive setting it could possibly have. That is what this
* test is really watching for.
*
* ⚠️ Opt-in via `FN_LIVE_FLEET`, and pointed at a throwaway relay. It creates
* accounts and uploads blobs.
*
* FN_LIVE_FLEET=http://127.0.0.1:8080 ./gradlew :client:test
*/
class LiveFileTransferTest {
private val fleet: String? = System.getenv("FN_LIVE_FLEET")
/**
* ⚠️ Announced, not silent. A `return` from an unmet precondition makes a
* test that did nothing report as PASSED — see the note in
* `LiveConversationTest`, which is where that lesson was paid for.
*/
private fun needFnLiveFleet() {
LiveTarget.refuseProduction(fleet)
assumeTrue(fleet != null, "set FN_LIVE_FLEET to run this against a relay")
assumeTrue(
LiveTarget.passphrase().isNotEmpty(),
"set FN_LIVE_PASSPHRASE — these tests create real accounts and this " +
"suite will not sign up with a value written into the source",
)
}
private class MapStorage : Vault.Storage {
private val records = LinkedHashMap<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
override fun delete(name: String) { records.remove(name) }
override fun names() = records.keys.toList()
}
/** Nothing is linked in these tests, so nothing should ever ask. */
private object NoReferences : LocalVault.ReferenceResolver {
override fun exists(bookmark: String) =
error("a vaulted file asked for a referenced one")
override fun read(bookmark: String): ByteArray =
error("a vaulted file asked for a referenced one")
override fun size(bookmark: String) =
error("a vaulted file asked for a referenced one")
}
private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
/** Somebody with a device, a relay session and a Files vault. */
private class Person(
val handle: String,
val userId: Long,
val identity: DeviceIdentity,
val conversations: Conversations,
val api: MessengerApi,
val files: LocalVault,
val root: File,
)
private fun signUp(base: String, tag: String): Person {
val handle = "livef$tag" + (System.nanoTime() % 10_000_000)
val account = RelayAccounts(base)
.signUp(handle, "$handle@example.invalid", LiveTarget.passphrase())
val identity = DeviceIdentity.create()
val vault = Vault(MapStorage(), key())
vault.saveIdentity(identity)
val relay = RelayClient(base, account.username, account.appPassword)
relay.useRelayAuth(identity)
relay.registerDevice(identity, identity.generateOneTimePreKeys(20))
vault.saveIdentity(identity)
// A real Files identity, so the label keys are the ones the app derives
// — 1,000 rounds rather than 650,000 because this asserts the format,
// not the work factor.
val raw = FilesIdentity.rawFromPkcs8(io.flamenet.e2e.X25519.generate().private.encoded)
val root = File.createTempFile("livevault$tag", "").let { it.delete(); it.mkdirs(); it }
return Person(
handle = handle,
userId = account.userId,
identity = identity,
conversations = Conversations(identity, vault, relay).also { it.myUserId = account.userId },
api = MessengerApi(base, account.username, account.appPassword),
files = LocalVault(root, raw, NoReferences).also { it.load() },
root = root,
)
}
private fun Conversations.readable(): List<Conversations.Received> =
poll().filter { it.content.isRenderable }
@Test
fun `a classified document crosses between two vaults with its label intact`() {
needFnLiveFleet()
val base = fleet!!
val alice = signUp(base, "a")
val bob = signUp(base, "b")
val bytes = "revenue: 41\nheadcount: 12\n".toByteArray()
val label = Label(3, setOf(1, 2))
val filed = alice.files.add(bytes, "Q3 Forecast.txt", label)
// Out of Alice's vault and onto the wire, exactly as `Session.sendFile`
// does it: plaintext read out, the transport seals it, the label rides
// alongside.
val plaintext = alice.files.contents(filed)
assertContentEquals(bytes, plaintext, "the vault handed back the wrong bytes")
val sent = alice.conversations.sendAttachment(
bob.userId, plaintext, "application/octet-stream", Attachment.FILE,
filename = "Q3 Forecast.txt",
classification = label.classification.toInt(),
compartments = label.compartments.toList(),
)
assertTrue(sent.reachedSomeone, "nothing was delivered: ${sent.failed}")
// In on Bob's side.
val received = bob.conversations.readable()
assertEquals(1, received.size, "Bob's inbox had ${received.size} items")
val ref = assertNotNull(received[0].content.attachment,
"the attachment did not survive the envelope")
assertEquals(Attachment.FILE, ref.kind)
assertEquals("Q3 Forecast.txt", ref.filename, "the document's own name was lost")
// 🔴 The assertion this test exists for.
assertEquals(3, ref.classification,
"the classification was lost in transit — the file would be filed Unclassified")
assertEquals(listOf(1, 2), ref.compartments.sorted(),
"the compartments were lost — the file would be filed outside its areas")
// And the bytes come back off the relay.
val fetched = bob.conversations.fetchAttachment(ref)
assertContentEquals(bytes, fetched, "the bytes did not survive the round trip")
// Bob saves it, at the label it arrived with — never one his phone chose.
val saved = bob.files.add(
fetched, ref.filename,
Label(ref.classification.toShort(), ref.compartments.toSet()),
)
val entry = bob.files.list(null).single()
assertEquals("Q3 Forecast.txt", entry.name)
assertEquals(label, entry.label,
"the document was filed under a different label than it was sent at")
assertContentEquals(bytes, bob.files.contents(saved))
// 🔴 **Two vaults, two identities, two different keys for one label.**
// The label key is derived from the identity, so the same
// classification does NOT mean the same key — which is what stops a
// stolen container from one phone opening anything on the other.
assertTrue(
!alice.files.labelKey(label).contentEquals(bob.files.labelKey(label)),
"two people derived the same key for one label — a stolen vault would open both",
)
alice.root.deleteRecursively()
bob.root.deleteRecursively()
}
/**
* ⚠️ An Unclassified document must arrive as one, and be distinguishable
* from a label that went missing. Zero is the default for an absent field,
* so this is the case where a dropped label and a deliberate zero look
* identical at the far end — the filename and the kind are what say the
* envelope was understood.
*/
@Test
fun `an unclassified document is still recognisably a document`() {
needFnLiveFleet()
val base = fleet!!
val alice = signUp(base, "c")
val bob = signUp(base, "d")
val bytes = "nothing secret here".toByteArray()
alice.conversations.sendAttachment(
bob.userId, bytes, "application/octet-stream", Attachment.FILE,
filename = "notes.txt", classification = 0, compartments = emptyList(),
)
val ref = assertNotNull(bob.conversations.readable().single().content.attachment)
assertEquals(Attachment.FILE, ref.kind, "an unlabelled file stopped being a file")
assertEquals("notes.txt", ref.filename)
assertEquals(0, ref.classification)
assertTrue(ref.compartments.isEmpty())
assertContentEquals(bytes, bob.conversations.fetchAttachment(ref))
alice.root.deleteRecursively()
bob.root.deleteRecursively()
}
}
@@ -40,6 +40,14 @@ class LiveKeyLogTest {
private fun needFnLiveFleet() {
LiveTarget.refuseProduction(base)
assumeTrue(base != null, "set FN_LIVE_FLEET to run this against a relay")
// 🔴 No fallback. This suite carried a literal passphrase and used it
// to create REAL accounts — a published password, since anybody who can
// read this repository can read it.
assumeTrue(
LiveTarget.passphrase().isNotEmpty(),
"set FN_LIVE_PASSPHRASE — this suite creates real accounts and will " +
"not sign up with a value written into the source",
)
}
private class MapStorage : Vault.Storage {
@@ -56,7 +64,7 @@ class LiveKeyLogTest {
val account = RelayAccounts(base).signUp(
username = handle,
email = "$handle@example.invalid",
password = "a-long-enough-passphrase",
password = LiveTarget.passphrase(),
)
val identity: DeviceIdentity = DeviceIdentity.create()
val vault = Vault(
@@ -39,6 +39,14 @@ class LiveSealedSenderTest {
private fun needFnLiveFleet() {
LiveTarget.refuseProduction(base)
assumeTrue(base != null, "set FN_LIVE_FLEET to run this against a relay")
// 🔴 No fallback. This suite carried a literal passphrase and used it
// to create REAL accounts — a published password, since anybody who can
// read this repository can read it.
assumeTrue(
LiveTarget.passphrase().isNotEmpty(),
"set FN_LIVE_PASSPHRASE — this suite creates real accounts and will " +
"not sign up with a value written into the source",
)
}
private class MapStorage : Vault.Storage {
@@ -54,7 +62,7 @@ class LiveSealedSenderTest {
val account = RelayAccounts(base).signUp(
username = handle,
email = "$handle@example.invalid",
password = "a-long-enough-passphrase",
password = LiveTarget.passphrase(),
)
val identity: DeviceIdentity = DeviceIdentity.create()
val vault = Vault(
@@ -34,6 +34,14 @@ class LiveSignUpTest {
private fun needFnLiveFleet() {
LiveTarget.refuseProduction(relay)
assumeTrue(relay != null, "set FN_LIVE_FLEET to run this against a relay")
// 🔴 No fallback. This suite carried a literal passphrase and used it
// to create REAL accounts — a published password, since anybody who can
// read this repository can read it.
assumeTrue(
LiveTarget.passphrase().isNotEmpty(),
"set FN_LIVE_PASSPHRASE — this suite creates real accounts and will " +
"not sign up with a value written into the source",
)
}
@Test
@@ -45,7 +53,7 @@ class LiveSignUpTest {
val account = RelayAccounts(base).signUp(
username = handle,
email = "$handle@example.invalid",
password = "a-long-enough-passphrase",
password = LiveTarget.passphrase(),
)
assertEquals(handle, account.username)
assertTrue(account.appPassword.isNotEmpty())
@@ -97,21 +97,59 @@ class TestSafetyTest {
fun `no live suite carries a credential literal`() {
val offenders = mutableListOf<String>()
/**
* A name that puts whatever follows it in a credential position.
*
* ⚠️ Deliberately NOT "secret". Test prose is full of it — a marker
* string called `secret` that the test then greps for in a container is
* a fixture, not a credential — and a check that cries wolf is a check
* somebody deletes.
*/
fun isCredentialName(name: String) = listOf("password", "passphrase", "credential")
.any { name.contains(it, ignoreCase = true) }
/** A literal that is plainly something else. */
fun isNotACredential(value: String) =
value.startsWith("http") || value.contains("@") || value.contains("/")
for (file in testSources()) {
val code = codeLines(file.readText())
if (!reachesALiveRelay(code)) continue
for (line in code) {
val looksLikeCredential = line.contains("PASSPHRASE") ||
line.contains("passphrase", ignoreCase = true) ||
line.contains("password", ignoreCase = true)
if (!looksLikeCredential) continue
// `val X = "…"` or `const val X = "…"` — an assignment to a
// non-empty literal. A getter that reads the environment has no
// `= "` on it and is the shape this wants people to use.
val assigned = Regex("""\bva[lr]\s+\w+\s*(:\s*String\s*)?=\s*"([^"]+)"""")
.find(line)?.groupValues?.getOrNull(2)
if (assigned != null) {
offenders += "${file.name}: $assigned"
for ((index, line) in code.withIndex()) {
for (literal in Regex(""""([^"\\$]{6,})"""").findAll(line)) {
val value = literal.groupValues[1]
if (isNotACredential(value)) continue
val before = line.substring(0, literal.range.first)
// 1. Anything assigned to a credential-shaped name, with or
// without `val` — `const val PASSPHRASE = "…"`,
// `password = "…"`, `passphrase = "…"`.
//
// 🔴 The `val` was once required, and `password = "…"` on
// its own line inside a multi-line argument list — the most
// ordinary Kotlin formatting there is — walked past it in
// three suites.
val assignedTo = Regex("""(\w+)\s*=\s*$""").find(before)?.groupValues?.get(1)
if (assignedTo != null && isCredentialName(assignedTo)) {
offenders += "${file.name}: $value (assigned to $assignedTo)"
continue
}
// 2. A positional literal handed to sign-up on the SAME
// line. Nothing else it could be.
//
// ⚠️ Same line only, and that is a deliberate limit rather
// than an oversight. Widening it to a few lines around the
// call flagged test tags, assertion messages and message
// bodies as credentials — forty of them — which is how a
// check stops being read. The multi-line case is covered by
// rule 1 instead, which sees `password = "…"` wherever it
// sits, and that is the formatting that actually occurs.
if (assignedTo != null) continue
if (Regex("""\bsign(Up|In)\s*\(""").containsMatchIn(before)) {
offenders += "${file.name}: $value (passed to sign-up)"
}
}
}
}