D4: history the user can carry — encrypted export and import
Same design as iOS: the vault's history re-sealed under a passphrase- derived key (PBKDF2 600k rounds, AES-256-GCM), saved through SAF, merged back by content identity so the same backup twice adds nothing. Expired stories stay expired — a backup is not a way back in. The passphrase is stored nowhere, deliberately. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
f1122777c1
commit
6a0f642e0d
@@ -660,6 +660,9 @@ fun ColumnScope.AccountScreen(
|
|||||||
SectionBand()
|
SectionBand()
|
||||||
SettingsSection { RegisteredDevicesSection(model, phase) }
|
SettingsSection { RegisteredDevicesSection(model, phase) }
|
||||||
|
|
||||||
|
SectionBand()
|
||||||
|
SettingsSection { HistoryBackupSection(model) }
|
||||||
|
|
||||||
SectionBand()
|
SectionBand()
|
||||||
SettingsSection { AddressCard(fnid = phase.fnid, relay = phase.relay, handle = phase.handle) }
|
SettingsSection { AddressCard(fnid = phase.fnid, relay = phase.relay, handle = phase.handle) }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
package io.flamenet.messenger.app
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import io.flamenet.e2e.HistoryExport
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export and import of encrypted history backups (D4).
|
||||||
|
*
|
||||||
|
* The relay keeps no history, so a new phone starts empty — this is the
|
||||||
|
* remedy, and the passphrase is deliberately the only key: the app stores
|
||||||
|
* nothing that could open the file, so neither can anyone who takes the file.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun HistoryBackupSection(model: MessengerViewModel) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var showExportDialog by remember { mutableStateOf(false) }
|
||||||
|
var pendingExport by remember { mutableStateOf<ByteArray?>(null) }
|
||||||
|
var pendingImport by remember { mutableStateOf<Uri?>(null) }
|
||||||
|
var working by remember { mutableStateOf(false) }
|
||||||
|
var status by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
val saveLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.CreateDocument("application/octet-stream")
|
||||||
|
) { uri ->
|
||||||
|
val bytes = pendingExport
|
||||||
|
pendingExport = null
|
||||||
|
if (uri == null || bytes == null) return@rememberLauncherForActivityResult
|
||||||
|
status = runCatching {
|
||||||
|
context.contentResolver.openOutputStream(uri)?.use { it.write(bytes) }
|
||||||
|
?: error("nothing to write to")
|
||||||
|
}.fold(
|
||||||
|
onSuccess = { "Backup saved. Keep the passphrase — it is the only key." },
|
||||||
|
onFailure = { "Couldn't write the file: ${it.message}" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val openLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.OpenDocument()
|
||||||
|
) { uri -> if (uri != null) pendingImport = uri }
|
||||||
|
|
||||||
|
Text("History backup", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
"Your history lives only on this phone — the server keeps none, so a "
|
||||||
|
+ "new phone starts empty. An export is sealed with a passphrase you "
|
||||||
|
+ "choose and can be imported on another Android phone signed into "
|
||||||
|
+ "this account. Lose the passphrase and the backup cannot be "
|
||||||
|
+ "opened, by anyone.",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = Palette.SecondaryText,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(Space.sm))
|
||||||
|
Row {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { showExportDialog = true },
|
||||||
|
enabled = !working,
|
||||||
|
modifier = Modifier.testTag("export-history"),
|
||||||
|
) { Text("Export…") }
|
||||||
|
Spacer(Modifier.width(Space.sm))
|
||||||
|
OutlinedButton(
|
||||||
|
// SAF filters on MIME, and a custom extension has none — offer
|
||||||
|
// everything and let the crypto refuse what isn't a backup.
|
||||||
|
onClick = { openLauncher.launch(arrayOf("*/*")) },
|
||||||
|
enabled = !working,
|
||||||
|
modifier = Modifier.testTag("import-history"),
|
||||||
|
) { Text("Import…") }
|
||||||
|
}
|
||||||
|
status?.let {
|
||||||
|
Spacer(Modifier.height(Space.xs))
|
||||||
|
Text(it, fontSize = 12.sp, color = Palette.SecondaryText,
|
||||||
|
modifier = Modifier.testTag("backup-status"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showExportDialog) {
|
||||||
|
PassphraseDialog(
|
||||||
|
title = "Export history",
|
||||||
|
explainer = "This passphrase is the only key to the backup. It isn't "
|
||||||
|
+ "stored anywhere — a forgotten passphrase means the backup can "
|
||||||
|
+ "never be opened.",
|
||||||
|
requireRepeat = true,
|
||||||
|
busy = working,
|
||||||
|
onDismiss = { if (!working) showExportDialog = false },
|
||||||
|
) { phrase ->
|
||||||
|
working = true
|
||||||
|
scope.launch {
|
||||||
|
val bytes = model.exportHistory(phrase.toCharArray())
|
||||||
|
working = false
|
||||||
|
showExportDialog = false
|
||||||
|
if (bytes == null) {
|
||||||
|
status = "Sign in first."
|
||||||
|
} else {
|
||||||
|
pendingExport = bytes
|
||||||
|
val stamp = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date())
|
||||||
|
saveLauncher.launch("flamenet-history-$stamp.${HistoryExport.FILE_EXTENSION}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingImport?.let { uri ->
|
||||||
|
PassphraseDialog(
|
||||||
|
title = "Import history",
|
||||||
|
explainer = "Anything already on this phone stays — the backup is "
|
||||||
|
+ "merged in, never a replacement.",
|
||||||
|
requireRepeat = false,
|
||||||
|
busy = working,
|
||||||
|
onDismiss = { if (!working) pendingImport = null },
|
||||||
|
) { phrase ->
|
||||||
|
working = true
|
||||||
|
scope.launch {
|
||||||
|
val bytes = runCatching {
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||||
|
}.getOrNull()
|
||||||
|
if (bytes == null) {
|
||||||
|
working = false
|
||||||
|
pendingImport = null
|
||||||
|
status = "Couldn't read that file."
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val outcome = model.importHistory(bytes, phrase.toCharArray())
|
||||||
|
working = false
|
||||||
|
pendingImport = null
|
||||||
|
status = outcome.fold(
|
||||||
|
onSuccess = { counts ->
|
||||||
|
if (counts.messages + counts.stories == 0)
|
||||||
|
"Backup opened — everything in it was already here."
|
||||||
|
else
|
||||||
|
"Imported ${counts.messages} message${if (counts.messages == 1) "" else "s"}" +
|
||||||
|
" and ${counts.stories} " +
|
||||||
|
if (counts.stories == 1) "story." else "stories."
|
||||||
|
},
|
||||||
|
onFailure = { it.message ?: "That backup couldn't be opened." },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PassphraseDialog(
|
||||||
|
title: String,
|
||||||
|
explainer: String,
|
||||||
|
requireRepeat: Boolean,
|
||||||
|
busy: Boolean,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
var phrase by remember { mutableStateOf("") }
|
||||||
|
var repeat by remember { mutableStateOf("") }
|
||||||
|
val mismatch = requireRepeat && repeat.isNotEmpty() && repeat != phrase
|
||||||
|
val ready = phrase.length >= 6 && (!requireRepeat || repeat == phrase)
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(title) },
|
||||||
|
text = {
|
||||||
|
androidx.compose.foundation.layout.Column {
|
||||||
|
Text(explainer, fontSize = 12.sp, color = Palette.SecondaryText)
|
||||||
|
Spacer(Modifier.height(Space.sm))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = phrase,
|
||||||
|
onValueChange = { phrase = it },
|
||||||
|
placeholder = { Text("Passphrase", fontSize = 14.sp) },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
modifier = Modifier.fillMaxWidth().testTag("backup-passphrase"),
|
||||||
|
)
|
||||||
|
if (requireRepeat) {
|
||||||
|
Spacer(Modifier.height(Space.xs))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = repeat,
|
||||||
|
onValueChange = { repeat = it },
|
||||||
|
placeholder = { Text("Repeat passphrase", fontSize = 14.sp) },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
isError = mismatch,
|
||||||
|
modifier = Modifier.fillMaxWidth().testTag("backup-passphrase-repeat"),
|
||||||
|
)
|
||||||
|
if (mismatch) {
|
||||||
|
Text("The two passphrases don't match yet.",
|
||||||
|
fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (phrase.isNotEmpty() && phrase.length < 6) {
|
||||||
|
Text("Use at least 6 characters.",
|
||||||
|
fontSize = 12.sp, color = Palette.SecondaryText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { onConfirm(phrase) },
|
||||||
|
enabled = ready && !busy,
|
||||||
|
modifier = Modifier.testTag("backup-confirm"),
|
||||||
|
) { Text(if (busy) "Working…" else "Continue") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss, enabled = !busy) { Text("Cancel") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import io.flamenet.e2e.AccountKdf
|
|||||||
import io.flamenet.e2e.DeviceIdentity
|
import io.flamenet.e2e.DeviceIdentity
|
||||||
import io.flamenet.e2e.Ed25519
|
import io.flamenet.e2e.Ed25519
|
||||||
import io.flamenet.e2e.FNID
|
import io.flamenet.e2e.FNID
|
||||||
|
import io.flamenet.e2e.HistoryExport
|
||||||
import io.flamenet.e2e.Vault
|
import io.flamenet.e2e.Vault
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import io.flamenet.client.IdentityTrust
|
import io.flamenet.client.IdentityTrust
|
||||||
@@ -1251,6 +1252,31 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- History backup (D4) -------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The vault's history, sealed under a passphrase the user chose.
|
||||||
|
*
|
||||||
|
* Off the main thread on purpose: the KDF runs 600k rounds, which is the
|
||||||
|
* point of it, and would freeze the UI for a visible beat.
|
||||||
|
*/
|
||||||
|
suspend fun exportHistory(passphrase: CharArray): ByteArray? =
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
vault?.let { HistoryExport.seal(it.exportPayload(), passphrase) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge a backup file in. Returns how much of it was actually new. */
|
||||||
|
suspend fun importHistory(bytes: ByteArray, passphrase: CharArray): Result<Vault.ImportCounts> =
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
val v = vault
|
||||||
|
?: return@withContext Result.failure(IllegalStateException("Sign in first."))
|
||||||
|
runCatching {
|
||||||
|
v.mergeImportedPayload(
|
||||||
|
HistoryExport.open(bytes, passphrase),
|
||||||
|
nowSeconds = System.currentTimeMillis() / 1000.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -- Stories (§6.6) ------------------------------------------------------
|
// -- Stories (§6.6) ------------------------------------------------------
|
||||||
|
|
||||||
fun refreshStories() = viewModelScope.launch {
|
fun refreshStories() = viewModelScope.launch {
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package io.flamenet.e2e
|
||||||
|
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.SecretKeyFactory
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
import javax.crypto.spec.PBEKeySpec
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A history backup the platform cannot read.
|
||||||
|
*
|
||||||
|
* The relay keeps no history — that is the product's whole promise — which
|
||||||
|
* means a new device genuinely starts empty. This is the designed remedy: the
|
||||||
|
* vault's history, re-sealed under a key derived from a passphrase the user
|
||||||
|
* chooses, so the file is safe to park anywhere and useless without the words.
|
||||||
|
*
|
||||||
|
* Same envelope as the iOS app (a JSON header naming the KDF around one
|
||||||
|
* AES-256-GCM box), but the v1 payload is platform-shaped, so the envelope is
|
||||||
|
* platform-tagged and a mismatched import is refused with an honest message
|
||||||
|
* rather than half-parsed.
|
||||||
|
*
|
||||||
|
* ⚠️ The passphrase is the only key. It is not stored anywhere — not in the
|
||||||
|
* Keystore, not in the file. A forgotten passphrase means the backup is gone,
|
||||||
|
* and that is the design, not a gap.
|
||||||
|
*/
|
||||||
|
object HistoryExport {
|
||||||
|
|
||||||
|
const val FILE_EXTENSION = "fnhistory"
|
||||||
|
|
||||||
|
/** High on purpose: runs once per export, prices out billions of guesses. */
|
||||||
|
private const val ITERATIONS = 600_000
|
||||||
|
private const val SALT_LEN = 16
|
||||||
|
private const val NONCE_LEN = 12
|
||||||
|
private const val TAG_BITS = 128
|
||||||
|
|
||||||
|
class NotAnExport : Exception("That file is not a Flamenet history export.")
|
||||||
|
class WrongPlatform(val platform: String) :
|
||||||
|
Exception("This backup was made on ${if (platform == "ios") "iPhone" else platform} — import it on a device of the same kind.")
|
||||||
|
class WrongPassphrase : Exception("That passphrase doesn't open this backup.")
|
||||||
|
|
||||||
|
fun seal(payload: JSONObject, passphrase: CharArray): ByteArray {
|
||||||
|
val salt = ByteArray(SALT_LEN).also(SecureRandom()::nextBytes)
|
||||||
|
val key = derive(passphrase, salt, ITERATIONS)
|
||||||
|
val nonce = ByteArray(NONCE_LEN).also(SecureRandom()::nextBytes)
|
||||||
|
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(TAG_BITS, nonce))
|
||||||
|
val sealed = cipher.doFinal(payload.toString().toByteArray(Charsets.UTF_8))
|
||||||
|
val envelope = JSONObject()
|
||||||
|
.put("magic", "fnhistory")
|
||||||
|
.put("v", 1)
|
||||||
|
.put("platform", "android")
|
||||||
|
.put("kdf", JSONObject()
|
||||||
|
.put("alg", "pbkdf2-sha256")
|
||||||
|
.put("salt", Bytes.b64(salt))
|
||||||
|
.put("iters", ITERATIONS))
|
||||||
|
.put("box", Bytes.b64(nonce + sealed))
|
||||||
|
return envelope.toString().toByteArray(Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun open(bytes: ByteArray, passphrase: CharArray): JSONObject {
|
||||||
|
val envelope = runCatching { JSONObject(String(bytes, Charsets.UTF_8)) }
|
||||||
|
.getOrNull() ?: throw NotAnExport()
|
||||||
|
val kdf = envelope.optJSONObject("kdf")
|
||||||
|
if (envelope.optString("magic") != "fnhistory" || envelope.optInt("v") != 1 ||
|
||||||
|
kdf == null || kdf.optString("alg") != "pbkdf2-sha256"
|
||||||
|
) throw NotAnExport()
|
||||||
|
val platform = envelope.optString("platform")
|
||||||
|
if (platform != "android") throw WrongPlatform(platform)
|
||||||
|
val salt = runCatching { Bytes.unb64(kdf.getString("salt")) }.getOrNull()
|
||||||
|
val combined = runCatching { Bytes.unb64(envelope.getString("box")) }.getOrNull()
|
||||||
|
if (salt == null || combined == null || combined.size <= NONCE_LEN) throw NotAnExport()
|
||||||
|
// The count comes from the file so an old export still opens after the
|
||||||
|
// default rises — clamped, because a hostile file naming ten billion
|
||||||
|
// rounds would wedge the import forever.
|
||||||
|
val iters = kdf.optInt("iters", ITERATIONS).coerceIn(100_000, 2_000_000)
|
||||||
|
val key = derive(passphrase, salt, iters)
|
||||||
|
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||||
|
cipher.init(
|
||||||
|
Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"),
|
||||||
|
GCMParameterSpec(TAG_BITS, combined.copyOfRange(0, NONCE_LEN)))
|
||||||
|
val plain = runCatching {
|
||||||
|
cipher.doFinal(combined.copyOfRange(NONCE_LEN, combined.size))
|
||||||
|
}.getOrNull() ?: throw WrongPassphrase()
|
||||||
|
return runCatching { JSONObject(String(plain, Charsets.UTF_8)) }
|
||||||
|
.getOrNull() ?: throw NotAnExport()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun derive(passphrase: CharArray, salt: ByteArray, iterations: Int): ByteArray =
|
||||||
|
SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
|
||||||
|
.generateSecret(PBEKeySpec(passphrase, salt, iterations, 256)).encoded
|
||||||
|
}
|
||||||
@@ -576,6 +576,112 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
|||||||
fun replaceHistory(threadId: String, messages: JSONArray) =
|
fun replaceHistory(threadId: String, messages: JSONArray) =
|
||||||
writeJson(historyName(threadId), JSONObject().put("messages", messages))
|
writeJson(historyName(threadId), JSONObject().put("messages", messages))
|
||||||
|
|
||||||
|
// -- History backup (D4) -------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything a history backup carries, as one JSON value.
|
||||||
|
*
|
||||||
|
* The outbox is deliberately left out: a pending send belongs to the
|
||||||
|
* device that queued it, and importing one elsewhere would either send a
|
||||||
|
* duplicate or strand a ghost that can never be flushed. Sessions, trust
|
||||||
|
* and keys are left out too — a backup restores what was said, never who
|
||||||
|
* this device is.
|
||||||
|
*/
|
||||||
|
fun exportPayload(): JSONObject {
|
||||||
|
val threadsObj = JSONObject()
|
||||||
|
threads().forEach { tid -> threadsObj.put(tid, history(tid)) }
|
||||||
|
return JSONObject()
|
||||||
|
.put("threads", threadsObj)
|
||||||
|
.put("callLog", callLog())
|
||||||
|
.also { p -> readJson(STORIES)?.let { p.put("stories", it) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ImportCounts(val messages: Int, val stories: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge a backup into what this device already has. Returns how much of
|
||||||
|
* it was actually new.
|
||||||
|
*
|
||||||
|
* Merge, never replace: the device has been receiving mail since it
|
||||||
|
* signed in, and an import that overwrote those rows would trade old
|
||||||
|
* history for new. Lines deduplicate by content (mine + timestamp + text
|
||||||
|
* + attachment), stories by their real id, calls by peer + timestamp —
|
||||||
|
* and the same backup imported twice adds nothing the second time.
|
||||||
|
*/
|
||||||
|
fun mergeImportedPayload(payload: JSONObject, nowSeconds: Double): ImportCounts {
|
||||||
|
var newMessages = 0
|
||||||
|
val threadsObj = payload.optJSONObject("threads") ?: JSONObject()
|
||||||
|
for (tid in threadsObj.keys()) {
|
||||||
|
val incoming = threadsObj.optJSONArray(tid) ?: continue
|
||||||
|
val current = history(tid)
|
||||||
|
val seen = HashSet<String>()
|
||||||
|
for (i in 0 until current.length()) seen.add(lineKey(current.getJSONObject(i)))
|
||||||
|
val fresh = ArrayList<JSONObject>()
|
||||||
|
for (i in 0 until incoming.length()) {
|
||||||
|
val m = incoming.optJSONObject(i) ?: continue
|
||||||
|
if (seen.add(lineKey(m))) fresh.add(m)
|
||||||
|
}
|
||||||
|
if (fresh.isEmpty()) continue
|
||||||
|
newMessages += fresh.size
|
||||||
|
val merged = ArrayList<JSONObject>(current.length() + fresh.size)
|
||||||
|
for (i in 0 until current.length()) merged.add(current.getJSONObject(i))
|
||||||
|
merged.addAll(fresh)
|
||||||
|
merged.sortBy { it.optLong("at") }
|
||||||
|
replaceHistory(tid, JSONArray().also { arr -> merged.forEach(arr::put) })
|
||||||
|
}
|
||||||
|
|
||||||
|
var newStories = 0
|
||||||
|
val importedStories = payload.optJSONObject("stories")?.optJSONArray("items")
|
||||||
|
if (importedStories != null && importedStories.length() > 0) {
|
||||||
|
val current = readJson(STORIES)?.optJSONArray("items") ?: JSONArray()
|
||||||
|
val known = HashSet<String>()
|
||||||
|
for (i in 0 until current.length()) known.add(current.getJSONObject(i).optString("id"))
|
||||||
|
for (i in 0 until importedStories.length()) {
|
||||||
|
val s = importedStories.optJSONObject(i) ?: continue
|
||||||
|
// An expired story must not sneak back in through a backup —
|
||||||
|
// the expiry is a promise about the bytes leaving the device.
|
||||||
|
val exp = s.optJSONObject("ref")?.optDouble("exp", 0.0) ?: 0.0
|
||||||
|
val active = exp == 0.0 || nowSeconds < exp
|
||||||
|
if (active && s.optString("id").isNotEmpty() && known.add(s.optString("id"))) {
|
||||||
|
current.put(s)
|
||||||
|
newStories++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJson(STORIES, JSONObject().put("items", current))
|
||||||
|
}
|
||||||
|
|
||||||
|
val importedCalls = payload.optJSONArray("callLog")
|
||||||
|
if (importedCalls != null && importedCalls.length() > 0) {
|
||||||
|
val current = callLog()
|
||||||
|
val known = HashSet<String>()
|
||||||
|
for (i in 0 until current.length()) {
|
||||||
|
val c = current.getJSONObject(i)
|
||||||
|
known.add("${c.optLong("peer")}|${c.optLong("at")}")
|
||||||
|
}
|
||||||
|
var changed = false
|
||||||
|
for (i in 0 until importedCalls.length()) {
|
||||||
|
val c = importedCalls.optJSONObject(i) ?: continue
|
||||||
|
if (known.add("${c.optLong("peer")}|${c.optLong("at")}")) {
|
||||||
|
current.put(c)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
val sorted = ArrayList<JSONObject>(current.length())
|
||||||
|
for (i in 0 until current.length()) sorted.add(current.getJSONObject(i))
|
||||||
|
sorted.sortBy { it.optLong("at") }
|
||||||
|
saveCallLog(JSONArray().also { arr -> sorted.forEach(arr::put) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ImportCounts(newMessages, newStories)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What makes a line *the same message* across two devices. */
|
||||||
|
private fun lineKey(m: JSONObject): String =
|
||||||
|
"${m.optBoolean("mine")}|${m.optLong("at")}|${m.optString("text")}|" +
|
||||||
|
(m.optJSONObject("att")?.optString("id") ?: "")
|
||||||
|
|
||||||
/** Every thread this device holds, group or person. */
|
/** Every thread this device holds, group or person. */
|
||||||
fun threads(): List<String> =
|
fun threads(): List<String> =
|
||||||
storage.names().filter { it.startsWith(HISTORY_PREFIX) }
|
storage.names().filter { it.startsWith(HISTORY_PREFIX) }
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package io.flamenet.e2e
|
||||||
|
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import javax.crypto.KeyGenerator
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The history backup opens only with the right words, and merging never loses
|
||||||
|
* what a device already has. The dedupe rules are the part worth guarding:
|
||||||
|
* the same backup imported twice must add nothing the second time.
|
||||||
|
*
|
||||||
|
* ⚠️ These run on the JVM. The crypto here (PBKDF2, AES-GCM via the platform
|
||||||
|
* `Cipher`) is deliberately the same JCA surface the vault already exercises
|
||||||
|
* on Android — but per the Conscrypt rule, the on-device instrumentation
|
||||||
|
* suite is what proves the Android side.
|
||||||
|
*/
|
||||||
|
class HistoryExportTest {
|
||||||
|
|
||||||
|
private class MapStorage : Vault.Storage {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun key(): SecretKey =
|
||||||
|
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||||
|
|
||||||
|
private fun vault() = Vault(MapStorage(), key())
|
||||||
|
|
||||||
|
private fun line(text: String, mine: Boolean, at: Long): JSONObject =
|
||||||
|
JSONObject().put("author", 0).put("text", text).put("mine", mine).put("at", at)
|
||||||
|
|
||||||
|
private fun populated(): Vault {
|
||||||
|
val v = vault()
|
||||||
|
v.append("user:42", line("packing now", true, 1_756_400_000_000))
|
||||||
|
v.append("user:42", line("bring the charger", false, 1_756_400_060_000))
|
||||||
|
v.append("group:g1", line("meeting moved", false, 1_756_400_120_000))
|
||||||
|
v.saveCallLog(JSONArray().put(JSONObject()
|
||||||
|
.put("peer", 42).put("in", true).put("ok", true).put("at", 1_756_400_500_000)))
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Sealing --------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a backup round trips with the right passphrase`() {
|
||||||
|
val sealed = HistoryExport.seal(populated().exportPayload(), "correct horse".toCharArray())
|
||||||
|
val back = HistoryExport.open(sealed, "correct horse".toCharArray())
|
||||||
|
assertEquals(2, back.getJSONObject("threads").getJSONArray("user:42").length())
|
||||||
|
assertEquals(1, back.getJSONObject("threads").getJSONArray("group:g1").length())
|
||||||
|
assertEquals(1, back.getJSONArray("callLog").length())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the wrong passphrase is refused as exactly that`() {
|
||||||
|
val sealed = HistoryExport.seal(populated().exportPayload(), "correct horse".toCharArray())
|
||||||
|
assertFailsWith<HistoryExport.WrongPassphrase> {
|
||||||
|
HistoryExport.open(sealed, "wrong pony".toCharArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `garbage is not an export`() {
|
||||||
|
assertFailsWith<HistoryExport.NotAnExport> {
|
||||||
|
HistoryExport.open("not json".toByteArray(), "x".toCharArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an iPhone backup is refused honestly`() {
|
||||||
|
val sealed = HistoryExport.seal(populated().exportPayload(), "pw-123456".toCharArray())
|
||||||
|
val doctored = JSONObject(String(sealed)).put("platform", "ios")
|
||||||
|
assertFailsWith<HistoryExport.WrongPlatform> {
|
||||||
|
HistoryExport.open(doctored.toString().toByteArray(), "pw-123456".toCharArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the outbox never leaves the device`() {
|
||||||
|
val v = populated()
|
||||||
|
v.saveOutbox(JSONArray().put(JSONObject().put("to", 42).put("text", "pending")))
|
||||||
|
assertTrue(!v.exportPayload().has("outbox"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Merging --------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `merging into an empty device takes everything`() {
|
||||||
|
val fresh = vault()
|
||||||
|
val counts = fresh.mergeImportedPayload(populated().exportPayload(), nowSeconds = 0.0)
|
||||||
|
assertEquals(3, counts.messages)
|
||||||
|
assertEquals(2, fresh.history("user:42").length())
|
||||||
|
assertEquals(1, fresh.callLog().length())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `merging keeps what the device already has`() {
|
||||||
|
val device = vault()
|
||||||
|
device.append("user:42", line("sent after the export", false, 1_756_500_000_000))
|
||||||
|
device.mergeImportedPayload(populated().exportPayload(), nowSeconds = 0.0)
|
||||||
|
val texts = device.history("user:42").let { arr ->
|
||||||
|
(0 until arr.length()).map { arr.getJSONObject(it).getString("text") }
|
||||||
|
}
|
||||||
|
assertEquals(listOf("packing now", "bring the charger", "sent after the export"), texts)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the same backup twice adds nothing the second time`() {
|
||||||
|
val device = vault()
|
||||||
|
val payload = populated().exportPayload()
|
||||||
|
device.mergeImportedPayload(payload, nowSeconds = 0.0)
|
||||||
|
val second = device.mergeImportedPayload(payload, nowSeconds = 0.0)
|
||||||
|
assertEquals(0, second.messages)
|
||||||
|
assertEquals(0, second.stories)
|
||||||
|
assertEquals(2, device.history("user:42").length())
|
||||||
|
assertEquals(1, device.callLog().length())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an expired story does not sneak back in through a backup`() {
|
||||||
|
val source = vault()
|
||||||
|
val items = JSONArray()
|
||||||
|
.put(JSONObject().put("id", "story-live").put("author", 42).put("at", 5)
|
||||||
|
.put("ref", JSONObject().put("exp", 0.0)))
|
||||||
|
.put(JSONObject().put("id", "story-dead").put("author", 42).put("at", 6)
|
||||||
|
.put("ref", JSONObject().put("exp", 100.0)))
|
||||||
|
// Write through the raw record shape the vault itself uses.
|
||||||
|
val payload = source.exportPayload().put("stories", JSONObject().put("items", items))
|
||||||
|
|
||||||
|
val device = vault()
|
||||||
|
val counts = device.mergeImportedPayload(payload, nowSeconds = 999.0)
|
||||||
|
assertEquals(1, counts.stories)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user