Merge branch 'fix/files-uses-your-flamenet-passphrase' into 'main'

Files asks for your Flamenet passphrase, not a second secret

See merge request paulhitt/flamenet-messenger-android!69
This commit was merged in pull request #96.
This commit is contained in:
Paul Hitt
2026-09-05 14:44:03 +00:00
3 changed files with 95 additions and 6 deletions
@@ -773,7 +773,7 @@ fun ColumnScope.AccountScreen(
Text("Files", fontWeight = FontWeight.Medium, fontSize = 16.sp)
Spacer(Modifier.height(Space.xs))
Text(
"Files encrypted on this phone, with a passphrase of their own. "
"Files encrypted on this phone, opened with your Flamenet passphrase. "
+ "Nothing is stored on a server.",
fontSize = 13.sp,
color = Palette.SecondaryText,
@@ -118,10 +118,22 @@ private fun ColumnScope.FilesSetUp(
var passphrase by remember { mutableStateOf("") }
var again by remember { mutableStateOf("") }
// 🔴 **"A passphrase of their own" told people to invent a second secret,
// and iOS never did.** iOS says "your passphrase" and takes whatever is
// typed, so a person naturally uses their Flamenet one and it simply
// works. Android's copy sent them off to make up something else, then
// asked for it again weeks later — and forgetting it is the one-way door
// described above this function.
//
// ⚠️ **Nothing about the derivation changes.** This is still a free-choice
// string protecting the identity record, so anyone who already set a
// different one keeps opening their files with it. Making the account
// passphrase the ACTUAL key would be a migration with permanent data loss
// on the far side of any mistake, and it is not what this fixes.
Text(
"Files on this phone are encrypted with a passphrase of their own. "
+ "Flamenet doesn't keep it, so nobody can open them by picking up "
+ "your unlocked phone.",
"Use your Flamenet passphrase — the one you sign in with. Files on "
+ "this phone are encrypted with it, and Flamenet keeps no copy, so "
+ "nobody can open them by picking up your unlocked phone.",
fontSize = 14.sp, color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.sm))
@@ -134,7 +146,7 @@ private fun ColumnScope.FilesSetUp(
OutlinedTextField(
value = passphrase, onValueChange = { passphrase = it },
label = { Text("Choose a passphrase") },
label = { Text("Your Flamenet passphrase") },
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("files-new-passphrase"),
@@ -168,7 +180,10 @@ private fun ColumnScope.FilesUnlock(
) {
var passphrase by remember { mutableStateOf("") }
Text("Enter your Files passphrase to open them.",
// ⚠️ Not "your Files passphrase". That name implied a second secret even
// for somebody who had used their Flamenet one, and left them hunting for
// a passphrase they never set.
Text("Enter your Flamenet passphrase to open them.",
fontSize = 14.sp, color = Palette.SecondaryText)
Spacer(Modifier.height(Space.md))
OutlinedTextField(
@@ -0,0 +1,74 @@
package io.flamenet.messenger.app
import java.io.File
import kotlin.test.Test
import kotlin.test.assertTrue
/**
* Files asks for the Flamenet passphrase, not a second secret.
*
* 🔴 **The screen used to say the files were encrypted "with a passphrase of
* their own", and iOS never said anything like it.** iOS says "your
* passphrase" and takes whatever is typed, so people use their Flamenet one
* and it works. Android's copy sent them off to invent something else — and
* forgetting it is a one-way door: the label keys are derived from the
* identity it protects and exist nowhere else, so every vaulted file becomes
* permanently unreadable.
*
* ⚠️ **This is about the words, and the words are the whole feature here.**
* Nothing in the derivation changed: it is still a free-choice string
* protecting the identity record, so anyone who already set a different one
* keeps opening their files with it. A guard on the copy is therefore the only
* guard there can be.
*/
class FilesUsesTheAccountPassphraseTest {
private fun source(name: String): String {
var dir = File(System.getProperty("user.dir"))
while (!File(dir, "app/src/main/kotlin").isDirectory && dir.parentFile != null) {
dir = dir.parentFile
}
val f = File(dir, "app/src/main/kotlin/io/flamenet/messenger/app/$name")
assertTrue(f.isFile, "could not find $name — this guard is reading nothing")
return f.readText()
}
/** Lines that are shown, not lines that explain. */
private fun copyLines(body: String): List<String> =
body.lines().filterNot {
val t = it.trimStart()
t.startsWith("//") || t.startsWith("*") || t.startsWith("/*")
}
@Test
fun `the setup screen names the Flamenet passphrase`() {
val shown = copyLines(source("FilesScreen.kt")).joinToString("\n")
assertTrue(
shown.contains("Use your Flamenet passphrase"),
"the Files setup screen no longer tells people to use the passphrase " +
"they already have, which is the whole of android's parity gap with iOS",
)
}
/**
* ⚠️ The exact phrase that caused it. Kept as a literal because a person
* rewording this screen should have to read the comment above the string
* and decide deliberately, not discover the consequence from a user.
*/
@Test
fun `no screen tells somebody to invent a second secret`() {
for (name in listOf("FilesScreen.kt", "ChatScreens.kt")) {
val shown = copyLines(source(name)).joinToString("\n")
assertTrue(
!shown.contains("passphrase of their own"),
"$name tells people the files have a passphrase of their own — " +
"iOS asks for the one they already have, and forgetting a " +
"second one destroys every vaulted file",
)
assertTrue(
!shown.contains("Choose a passphrase"),
"$name still asks somebody to choose a new passphrase for Files",
)
}
}
}