diff --git a/apps/android/app/src/main/java/com/codedeck/plus/MainActivity.kt b/apps/android/app/src/main/java/com/codedeck/plus/MainActivity.kt index 41fe73a6..1c024aff 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/MainActivity.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/MainActivity.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity @@ -48,6 +49,7 @@ import com.codedeck.plus.platform.installedSignerApps import com.codedeck.plus.platform.signerAnswerOf import com.codedeck.plus.ui.OpenSessionRequest import com.codedeck.plus.ui.Shell +import com.codedeck.plus.ui.screens.RestoreContent import com.codedeck.plus.ui.screens.WelcomeBusy import com.codedeck.plus.ui.screens.WelcomeScreen import com.codedeck.plus.ui.theme.CodeDeckTheme @@ -87,6 +89,9 @@ class MainViewModel : ViewModel() { /** Whether a login exists; until one does, the welcome screen shows and * no core runs. */ val loggedIn = MutableStateFlow(null) + /** Whether to offer restoring a backup before the app proper: once, + * right after logging in with a key that may have one. */ + val offerRestore = MutableStateFlow(false) val welcomeBusy = MutableStateFlow(null) val welcomeError = MutableStateFlow(null) val signers = MutableStateFlow>(emptyList()) @@ -209,7 +214,8 @@ class MainActivity : ComponentActivity() { busy = busy, error = error, onUseSigner = ::useSigner, - onCreateKey = { logIn(Login.OnDevice) { vault -> vault.setIdentity(freshSecretHex()) } }, + // A key made just now has no backup to restore. + onCreateKey = { logIn(Login.OnDevice, offerRestore = false) { vault -> vault.setIdentity(freshSecretHex()) } }, onImportKey = ::importKey, ) } else if (current != null) { @@ -217,12 +223,22 @@ class MainActivity : ComponentActivity() { // and sp text scale as one, like the TSX multiplier. val settings by current.settings.collectAsState() val openRequest by viewModel.openRequest.collectAsState() + val offerRestore by viewModel.offerRestore.collectAsState() val scale = settings?.uiScale?.toFloat() ?: 1f val d = LocalDensity.current + val restoreFrom = settings CompositionLocalProvider( LocalDensity provides Density(d.density * scale, d.fontScale * scale), ) { - Shell( + if (offerRestore && restoreFrom != null) { + val scope = rememberCoroutineScope() + RestoreContent( + backup = restoreFrom.backup, + torOn = restoreFrom.torProxyEnabled, + dispatch = { intent -> scope.launch { current.dispatch(intent) } }, + onDone = { viewModel.offerRestore.value = false }, + ) + } else Shell( current, openRequest = openRequest, onOpenRequestHandled = viewModel::openRequestHandled, @@ -323,7 +339,7 @@ class MainActivity : ComponentActivity() { /** Store `login` (after `prepare` set up its keys, off the main thread), * give it a fresh session key, and start the core. */ - private fun logIn(login: Login, prepare: (KeyVault) -> Unit) { + private fun logIn(login: Login, offerRestore: Boolean = true, prepare: (KeyVault) -> Unit) { viewModel.welcomeError.value = null if (viewModel.welcomeBusy.value == null) viewModel.welcomeBusy.value = WelcomeBusy.Key lifecycleScope.launch { @@ -340,6 +356,7 @@ class MainActivity : ComponentActivity() { viewModel.welcomeError.value = "Could not store the key on this device." return@launch } + viewModel.offerRestore.value = offerRestore viewModel.loggedIn.value = true startCore() bind() diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/BackupPage.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/BackupPage.kt new file mode 100644 index 00000000..4b9c3785 --- /dev/null +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/BackupPage.kt @@ -0,0 +1,360 @@ +package com.codedeck.plus.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CloudSync +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.codedeck.plus.ui.components.ActionRow +import com.codedeck.plus.ui.components.Dot +import com.codedeck.plus.ui.components.ErrorNote +import com.codedeck.plus.ui.components.Field +import com.codedeck.plus.ui.components.Group +import com.codedeck.plus.ui.components.GroupBody +import com.codedeck.plus.ui.components.Page +import com.codedeck.plus.ui.components.PrimaryButton +import com.codedeck.plus.ui.components.QuietButton +import com.codedeck.plus.ui.components.SecondaryButton +import com.codedeck.plus.ui.components.ValueRow +import com.codedeck.plus.ui.theme.Tokens +import uniffi.client_ffi.UniffiBackupStatus +import uniffi.client_ffi.UniffiBackupView +import uniffi.client_ffi.UniffiIntent +import java.text.DateFormat +import java.util.Date + +/** What the backup holds, said once for the settings page and the restore step. */ +private const val BACKUP_HOLDS = + "Your paired machines, settings, quick prompts and this phone's session keys, encrypted to your key: only you can read it." + +/** A relay address the backup may use: `wss://` with a host, `ws://` only to an onion service. */ +internal fun isBackupRelay(url: String): Boolean { + val trimmed = url.trim() + if (trimmed.any { it.isWhitespace() }) return false + val host = trimmed.substringAfter("://").substringBefore('/').substringBefore(':').lowercase() + return when { + trimmed.startsWith("wss://") -> host.isNotEmpty() + trimmed.startsWith("ws://") -> host.endsWith(".onion") && host.length > ".onion".length + else -> false + } +} + +/** A relay's host, for saying where the backup is. */ +internal fun relayHost(url: String): String = url.trim().substringAfter("://").substringBefore('/') + +/** When something happened, as a person says it: "just now", "5 min ago", "3 h ago", or the date. */ +internal fun whenSaved(at: Long, now: Long): String { + val minutes = (now - at) / 60_000 + return when { + minutes < 1 -> "just now" + minutes < 60 -> "$minutes min ago" + minutes < 24 * 60 -> "${minutes / 60} h ago" + else -> "on " + DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(at)) + } +} + +private fun machinesText(n: UInt): String = when (n) { + 0u -> "no machines" + 1u -> "1 machine" + else -> "$n machines" +} + +/** + * The config backup: off, it asks for a relay; on, it says when it last + * saved and offers a save now and turning it off. A backup found on the + * relay waits for a choice before anything is saved over it. + */ +@Composable +internal fun BackupPage( + backup: UniffiBackupView, + torOn: Boolean, + now: Long, + dispatch: (UniffiIntent) -> Unit, + onBack: () -> Unit, +) { + Page(title = "Backup", onBack = onBack) { + val relay = backup.relay + if (relay == null) { + BackupOff(backup.status, torOn, dispatch) + } else { + BackupOn(relay, backup, now, dispatch) + } + } +} + +@Composable +private fun BackupOff(status: UniffiBackupStatus, torOn: Boolean, dispatch: (UniffiIntent) -> Unit) { + var draft by rememberSaveable { mutableStateOf("") } + val valid = isBackupRelay(draft) + Group( + title = "Back up to a relay", + footer = "$BACKUP_HOLDS A new phone logged in as you restores it from the same relay. The relay only sees that " + + "you saved something, and when." + if (torOn) " It goes through Orbot, like your other relays." else "", + ) { + GroupBody { + Field( + value = draft, + onValueChange = { draft = it }, + placeholder = "wss://relay.example.com", + mono = true, + isError = draft.isNotBlank() && !valid, + supporting = when { + draft.isNotBlank() && !valid -> "Use a wss:// address, or ws:// for a .onion." + status is UniffiBackupStatus.Failed -> status.reason + else -> null + }, + ) + SecondaryButton("Turn on backup", onClick = { dispatch(UniffiIntent.SetBackupRelay(draft.trim())) }, enabled = valid) + } + } +} + +@Composable +private fun BackupOn(relay: String, backup: UniffiBackupView, now: Long, dispatch: (UniffiIntent) -> Unit) { + val status = backup.status + if (status is UniffiBackupStatus.Found) { + FoundBackup(status, relay, now, dispatch) + return + } + var turningOff by remember { mutableStateOf(false) } + Group(footer = "$BACKUP_HOLDS It saves itself a little after each change.") { + GroupBody { BackupStatusLine(status, backup.savedAt?.toLong(), now) } + ValueRow("Relay", subtitle = relay, mono = true) {} + GroupBody { + SecondaryButton( + "Back up now", + onClick = { dispatch(UniffiIntent.BackupNow) }, + enabled = status !is UniffiBackupStatus.Saving && status !is UniffiBackupStatus.Checking, + ) + } + } + Group { + if (turningOff) { + GroupBody { + Text( + "Turn off backup? This phone stops saving to ${relayHost(relay)}. You can also ask the relay to delete " + + "what is there; relays usually do, but none has to.", + color = Tokens.Text, + fontSize = Tokens.TextSm, + ) + Column(verticalArrangement = Arrangement.spacedBy(Tokens.Space2)) { + SecondaryButton("Turn off and delete it", danger = true, onClick = { + turningOff = false + dispatch(UniffiIntent.DisableBackup(delete = true)) + }) + Row(horizontalArrangement = Arrangement.spacedBy(Tokens.Space2)) { + SecondaryButton("Turn off, keep it", onClick = { + turningOff = false + dispatch(UniffiIntent.DisableBackup(delete = false)) + }) + QuietButton("Cancel", onClick = { turningOff = false }) + } + } + } + } else { + ActionRow("Turn off backup", onClick = { turningOff = true }, danger = true) + } + } +} + +/** One line of what the backup is doing, with a dot or a spinner. */ +@Composable +private fun BackupStatusLine(status: UniffiBackupStatus, savedAt: Long?, now: Long) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Tokens.Space2)) { + val busy = status is UniffiBackupStatus.Checking || status is UniffiBackupStatus.Saving || status is UniffiBackupStatus.Importing + if (busy) { + CircularProgressIndicator(Modifier.size(12.dp), color = Tokens.TextMuted, strokeWidth = 1.5.dp) + } else { + Dot( + when { + status is UniffiBackupStatus.Failed -> Tokens.Danger + savedAt != null -> Tokens.Success + else -> Tokens.TextDim + }, + ) + } + Text( + when (status) { + UniffiBackupStatus.Checking -> "Looking for a backup…" + UniffiBackupStatus.Saving -> "Backing up…" + UniffiBackupStatus.Importing -> "Restoring…" + is UniffiBackupStatus.Failed -> "Not backed up" + else -> if (savedAt != null) "Backed up ${whenSaved(savedAt, now)}" else "Not backed up yet" + }, + color = Tokens.Text, + fontSize = Tokens.TextMd, + ) + } + if (status is UniffiBackupStatus.Failed) ErrorNote(status.reason) +} + +/** A backup is on the relay already: restore it, or let this phone's replace it. */ +@Composable +private fun FoundBackup(found: UniffiBackupStatus.Found, relay: String, now: Long, dispatch: (UniffiIntent) -> Unit) { + var replacing by remember { mutableStateOf(false) } + Group( + title = "A backup is already there", + footer = "Restoring adds the machines this phone does not have and takes the backup's settings and quick " + + "prompts. Nothing is saved to the relay until you choose.", + ) { + GroupBody { + FoundSummary(found, relay, now) + if (replacing) { + Text( + "Replace it with this phone's setup? What the backup holds and this phone lacks is lost.", + color = Tokens.Danger, + fontSize = Tokens.TextSm, + ) + Row(horizontalArrangement = Arrangement.spacedBy(Tokens.Space2)) { + SecondaryButton("Replace it", danger = true, onClick = { dispatch(UniffiIntent.KeepLocalConfig) }) + QuietButton("Cancel", onClick = { replacing = false }) + } + } else { + Row(horizontalArrangement = Arrangement.spacedBy(Tokens.Space2), verticalAlignment = Alignment.CenterVertically) { + PrimaryButton("Restore", onClick = { dispatch(UniffiIntent.ImportBackup) }) + QuietButton("Keep this phone's", onClick = { replacing = true }) + } + } + } + } +} + +/** The found backup's date and size, the thing the choice is about. */ +@Composable +private fun FoundSummary(found: UniffiBackupStatus.Found, relay: String, now: Long) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Tokens.Space3)) { + Icon(Icons.Outlined.CloudSync, contentDescription = null, tint = Tokens.Text, modifier = Modifier.size(28.dp)) + Column { + Text( + "Saved ${whenSaved(found.savedAt.toLong(), now)}", + color = Tokens.Text, + fontSize = Tokens.TextMd, + fontWeight = FontWeight.Medium, + ) + Text("${machinesText(found.machines)}, on ${relayHost(relay)}", color = Tokens.TextMuted, fontSize = Tokens.TextSm) + } + } +} + +/** + * Right after logging in: restore a backup made on another phone, or skip. + * Shown once, before the app proper; [onDone] leaves for the app. + */ +@Composable +internal fun RestoreContent( + backup: UniffiBackupView, + torOn: Boolean, + dispatch: (UniffiIntent) -> Unit, + onDone: () -> Unit, + now: Long = System.currentTimeMillis(), +) { + var draft by rememberSaveable { mutableStateOf("") } + // Set once Restore is tapped, so the idle state after it reads as done + // rather than as "nothing was there". + var restoring by rememberSaveable { mutableStateOf(false) } + val status = backup.status + val relay = backup.relay + val skip = { + if (relay != null) dispatch(UniffiIntent.DisableBackup(delete = false)) + onDone() + } + + BoxWithConstraints(Modifier.fillMaxSize().background(Tokens.Bg), contentAlignment = Alignment.TopCenter) { + val viewport = maxHeight + Column( + Modifier + .verticalScroll(rememberScrollState()) + .widthIn(max = 560.dp) + .fillMaxWidth() + .heightIn(min = viewport) + .padding(horizontal = Tokens.Space5, vertical = Tokens.Space6), + verticalArrangement = Arrangement.spacedBy(Tokens.Space5, Alignment.CenterVertically), + ) { + Column(verticalArrangement = Arrangement.spacedBy(Tokens.Space2)) { + Text("Restore your setup?", color = Tokens.Text, fontSize = 28.sp, fontWeight = FontWeight.SemiBold, letterSpacing = (-0.3).sp) + Text( + "If you backed up another phone, enter the relay it saved to: its machines, settings and quick " + + "prompts come back here. The backup is encrypted to your key, so only you can read it.", + color = Tokens.TextMuted, + fontSize = Tokens.TextMd, + lineHeight = 20.sp, + ) + } + when { + status is UniffiBackupStatus.Found && relay != null -> Column(verticalArrangement = Arrangement.spacedBy(Tokens.Space4)) { + FoundSummary(status, relay, now) + Row(horizontalArrangement = Arrangement.spacedBy(Tokens.Space2), verticalAlignment = Alignment.CenterVertically) { + PrimaryButton("Restore", onClick = { + restoring = true + dispatch(UniffiIntent.ImportBackup) + }) + QuietButton("Skip", onClick = skip) + } + } + status is UniffiBackupStatus.Checking || status is UniffiBackupStatus.Importing || status is UniffiBackupStatus.Saving -> + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Tokens.Space3)) { + CircularProgressIndicator(Modifier.size(18.dp), color = Tokens.TextMuted, strokeWidth = 2.dp) + Text( + if (status is UniffiBackupStatus.Checking) "Looking on ${relayHost(relay.orEmpty())}…" else "Restoring…", + color = Tokens.Text, + fontSize = Tokens.TextMd, + ) + } + relay != null && status !is UniffiBackupStatus.Failed -> Column(verticalArrangement = Arrangement.spacedBy(Tokens.Space4)) { + Text( + if (restoring) { + "Restored. Your machines reconnect as each one is heard from. Backup stays on, so this phone saves its changes there too." + } else { + "No backup on ${relayHost(relay)} yet. Backup is on, so this phone saves its setup there from now on." + }, + color = Tokens.Text, + fontSize = Tokens.TextMd, + lineHeight = 20.sp, + ) + PrimaryButton("Continue", onClick = onDone) + } + else -> Column(verticalArrangement = Arrangement.spacedBy(Tokens.Space3)) { + val valid = isBackupRelay(draft) + Field( + value = draft, + onValueChange = { draft = it }, + placeholder = "wss://relay.example.com", + mono = true, + isError = draft.isNotBlank() && !valid, + supporting = if (draft.isNotBlank() && !valid) "Use a wss:// address, or ws:// for a .onion." else null, + ) + if (status is UniffiBackupStatus.Failed) ErrorNote(status.reason) + Row(horizontalArrangement = Arrangement.spacedBy(Tokens.Space2), verticalAlignment = Alignment.CenterVertically) { + PrimaryButton("Look for a backup", onClick = { dispatch(UniffiIntent.SetBackupRelay(draft.trim())) }, enabled = valid) + QuietButton("Skip", onClick = skip) + } + if (torOn) Text("Goes through Orbot.", color = Tokens.TextDim, fontSize = Tokens.TextXs) + } + } + } + } +} diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/SettingsScreen.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/SettingsScreen.kt index e0d75066..c77445f9 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/SettingsScreen.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/SettingsScreen.kt @@ -1,7 +1,11 @@ package com.codedeck.plus.ui.screens +import android.content.ClipData +import android.content.ClipDescription +import android.content.ClipboardManager import android.content.Context import android.content.Intent +import android.os.PersistableBundle import android.provider.Settings import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement @@ -13,6 +17,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.Article import androidx.compose.material.icons.automirrored.outlined.Chat import androidx.compose.material.icons.outlined.AccountCircle +import androidx.compose.material.icons.outlined.CloudSync import androidx.compose.material.icons.outlined.CloudUpload import androidx.compose.material.icons.outlined.NotificationsNone import androidx.compose.material.icons.outlined.SettingsEthernet @@ -37,6 +42,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.codedeck.plus.BuildConfig import com.codedeck.plus.core.CoreHost +import com.codedeck.plus.platform.KeyVault import com.codedeck.plus.platform.Login import com.codedeck.plus.platform.StayConnectedService import com.codedeck.plus.ui.components.ActionRow @@ -55,8 +61,13 @@ import com.codedeck.plus.ui.components.SwitchRow import com.codedeck.plus.ui.components.ValueRow import com.codedeck.plus.ui.components.machineLabel import com.codedeck.plus.ui.theme.Tokens +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import uniffi.client_ffi.UniffiBackupStatus +import uniffi.client_ffi.UniffiBackupView import uniffi.client_ffi.UniffiIntent +import uniffi.client_ffi.nsecOf import uniffi.client_ffi.UniffiMachineSummary import uniffi.client_ffi.UniffiQuickPrompt import uniffi.client_ffi.UniffiSettingsView @@ -81,6 +92,7 @@ internal sealed interface SettingsPage { data object Connection : SettingsPage data object Messages : SettingsPage data object Uploads : SettingsPage + data object Backup : SettingsPage data object Account : SettingsPage fun save(): String = when (this) { @@ -93,6 +105,7 @@ internal sealed interface SettingsPage { Connection -> "connection" Messages -> "messages" Uploads -> "uploads" + Backup -> "backup" Account -> "account" } @@ -114,6 +127,7 @@ internal sealed interface SettingsPage { saved == "connection" -> Connection saved == "messages" -> Messages saved == "uploads" -> Uploads + saved == "backup" -> Backup saved == "account" -> Account else -> Hub } @@ -224,7 +238,20 @@ fun SettingsScreen( } SettingsPage.Messages -> MessagesPage(view, quickPrompts?.prompts.orEmpty(), ::dispatch, toHub) SettingsPage.Uploads -> UploadsPage(view, ::dispatch, toHub) - SettingsPage.Account -> AccountPage(npub, signerLabel, onLogOut, toHub) + SettingsPage.Backup -> BackupPage(view.backup, view.torProxyEnabled, System.currentTimeMillis(), ::dispatch, toHub) + SettingsPage.Account -> AccountPage( + npub, + signerLabel, + onLogOut, + toHub, + // Only a key kept on this phone can be shown; reading it + // touches the Keystore and a file, so not on the main thread. + revealKey = if (login is Login.OnDevice) { + { withContext(Dispatchers.IO) { KeyVault(context).identitySecretHex()?.let { nsecOf(it) } } } + } else { + null + }, + ) } } } @@ -296,6 +323,13 @@ internal fun SettingsHub( icon = { RowIcon(Icons.Outlined.CloudUpload) }, subtitle = if (view.blossomServer.isNotBlank()) "To ${blossomHost(view.blossomServer)}" else "Through the relays", ) + Divider(inset = 68.dp) + NavRow( + "Backup", + onClick = { onOpen(SettingsPage.Backup) }, + icon = { RowIcon(Icons.Outlined.CloudSync) }, + subtitle = backupSummary(view.backup, now), + ) } Group { NavRow("Logs", onClick = onOpenLogs, icon = { RowIcon(Icons.AutoMirrored.Outlined.Article) }) @@ -316,6 +350,16 @@ internal fun SettingsHub( } } +/** The hub's line for the backup: off, waiting on a choice, failing, or when it last saved. */ +private fun backupSummary(backup: UniffiBackupView, now: Long): String { + val relay = backup.relay ?: return "Off" + return when (val status = backup.status) { + is UniffiBackupStatus.Found -> "A backup on ${relayHost(relay)} waits for you" + is UniffiBackupStatus.Failed -> "Not backed up" + else -> backup.savedAt?.let { "Backed up ${whenSaved(it.toLong(), now)}" } ?: "On, to ${relayHost(relay)}" + } +} + private fun sessionCount(machine: UniffiMachineSummary): String = when (val n = machine.sessions.size) { 0 -> "" 1 -> ", 1 session" @@ -506,6 +550,50 @@ private fun NewQuickPrompt(dispatch: (UniffiIntent) -> Unit) { } } +/** + * The secret key of a login kept on this phone, hidden until asked for: + * it is the only way back into the identity on another phone or after + * logging out. Copied as sensitive, so the keyboard's clipboard preview + * does not show it. + */ +@Composable +private fun SecretKeyGroup(revealKey: suspend () -> String?) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var nsec by remember { mutableStateOf(null) } + var copied by remember { mutableStateOf(false) } + Group(footer = "Anyone with your secret key is you. Keep a copy somewhere safe and never share it.") { + val shown = nsec + if (shown == null) { + ValueRow("Secret key", subtitle = "Hidden") { + QuietButton("Show", onClick = { scope.launch { nsec = revealKey() } }) + } + } else { + GroupBody { + Text(shown, color = Tokens.Text, fontSize = Tokens.TextSm, fontFamily = Tokens.FontMono) + Row(horizontalArrangement = Arrangement.spacedBy(Tokens.Space2), verticalAlignment = Alignment.CenterVertically) { + SecondaryButton(if (copied) "Copied" else "Copy", onClick = { + copySensitive(context, shown) + copied = true + }) + QuietButton("Hide", onClick = { + nsec = null + copied = false + }) + } + } + } + } +} + +/** Put a secret on the clipboard, flagged so Android does not preview it. */ +private fun copySensitive(context: Context, text: String) { + val clipboard = context.getSystemService(ClipboardManager::class.java) ?: return + val clip = ClipData.newPlainText("Secret key", text) + clip.description.extras = PersistableBundle().apply { putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true) } + clipboard.setPrimaryClip(clip) +} + /** * Who this phone is logged in as, and logging out. Logging out deletes * everything the phone keeps for the identity (paired machines, transcripts, @@ -513,7 +601,13 @@ private fun NewQuickPrompt(dispatch: (UniffiIntent) -> Unit) { * why the confirmation says so plainly. */ @Composable -internal fun AccountPage(npub: String, signerLabel: String?, onLogOut: () -> Unit, onBack: () -> Unit) { +internal fun AccountPage( + npub: String, + signerLabel: String?, + onLogOut: () -> Unit, + onBack: () -> Unit, + revealKey: (suspend () -> String?)? = null, +) { var confirming by remember { mutableStateOf(false) } Page(title = "Account", onBack = onBack) { Group(footer = "Machines and relays know this phone by this key.") { @@ -523,6 +617,7 @@ internal fun AccountPage(npub: String, signerLabel: String?, onLogOut: () -> Uni mono = true, ) {} } + if (revealKey != null) SecretKeyGroup(revealKey) Group { if (confirming) { GroupBody { diff --git a/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt b/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt index 2541187e..9e438cc3 100644 --- a/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt +++ b/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt @@ -855,6 +855,8 @@ internal object IntegrityCheckingUniffiLib { ): Int external fun uniffi_client_ffi_checksum_func_is_valid_provider_base_url( ): Int + external fun uniffi_client_ffi_checksum_func_nsec_of( + ): Int external fun uniffi_client_ffi_checksum_func_persisted_tor_proxy_enabled( ): Int external fun uniffi_client_ffi_checksum_func_provider_base_url_error( @@ -1065,6 +1067,8 @@ internal object UniffiLib { ): Byte external fun uniffi_client_ffi_fn_func_is_valid_provider_base_url(`raw`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Byte + external fun uniffi_client_ffi_fn_func_nsec_of(`secretHex`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue external fun uniffi_client_ffi_fn_func_persisted_tor_proxy_enabled(`dbPath`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Byte external fun uniffi_client_ffi_fn_func_provider_base_url_error(uniffi_out_err: UniffiRustCallStatus, @@ -1209,6 +1213,9 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if ((lib.uniffi_client_ffi_checksum_func_is_valid_provider_base_url() and 0xFFFF) != 1205) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if ((lib.uniffi_client_ffi_checksum_func_nsec_of() and 0xFFFF) != 40173) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if ((lib.uniffi_client_ffi_checksum_func_persisted_tor_proxy_enabled() and 0xFFFF) != 37378) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -4871,6 +4878,58 @@ public object FfiConverterTypeUniffiAvailablePlugin: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): UniffiBackupView { + return UniffiBackupView( + FfiConverterOptionalString.read(buf), + FfiConverterOptionalULong.read(buf), + FfiConverterTypeUniffiBackupStatus.read(buf), + ) + } + + override fun allocationSize(value: UniffiBackupView) = ( + FfiConverterOptionalString.allocationSize(value.`relay`) + + FfiConverterOptionalULong.allocationSize(value.`savedAt`) + + FfiConverterTypeUniffiBackupStatus.allocationSize(value.`status`) + ) + + override fun write(value: UniffiBackupView, buf: ByteBuffer) { + FfiConverterOptionalString.write(value.`relay`, buf) + FfiConverterOptionalULong.write(value.`savedAt`, buf) + FfiConverterTypeUniffiBackupStatus.write(value.`status`, buf) + } +} + + + /** * A credential's status — the secret itself never crosses. */ @@ -7246,6 +7305,8 @@ data class UniffiSettingsView ( var `showUsageBadge`: kotlin.Boolean , var `showCommitBadge`: kotlin.Boolean + , + var `backup`: UniffiBackupView ){ @@ -7270,6 +7331,7 @@ public object FfiConverterTypeUniffiSettingsView: FfiConverterRustBuffer{ + override fun read(buf: ByteBuffer): UniffiBackupStatus { + return when(buf.getInt()) { + 1 -> UniffiBackupStatus.Idle + 2 -> UniffiBackupStatus.Checking + 3 -> UniffiBackupStatus.Found( + FfiConverterULong.read(buf), + FfiConverterUInt.read(buf), + ) + 4 -> UniffiBackupStatus.Saving + 5 -> UniffiBackupStatus.Importing + 6 -> UniffiBackupStatus.Failed( + FfiConverterString.read(buf), + ) + else -> throw RuntimeException("invalid enum value, something is very wrong!!") + } + } + + override fun allocationSize(value: UniffiBackupStatus): ULong = when(value) { + is UniffiBackupStatus.Idle -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + is UniffiBackupStatus.Checking -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + is UniffiBackupStatus.Found -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterULong.allocationSize(value.`savedAt`) + + FfiConverterUInt.allocationSize(value.`machines`) + ) + } + is UniffiBackupStatus.Saving -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + is UniffiBackupStatus.Importing -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + is UniffiBackupStatus.Failed -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterString.allocationSize(value.`reason`) + ) + } + } + + override fun write(value: UniffiBackupStatus, buf: ByteBuffer) { + when(value) { + is UniffiBackupStatus.Idle -> { + buf.putInt(1) + Unit + } + is UniffiBackupStatus.Checking -> { + buf.putInt(2) + Unit + } + is UniffiBackupStatus.Found -> { + buf.putInt(3) + FfiConverterULong.write(value.`savedAt`, buf) + FfiConverterUInt.write(value.`machines`, buf) + Unit + } + is UniffiBackupStatus.Saving -> { + buf.putInt(4) + Unit + } + is UniffiBackupStatus.Importing -> { + buf.putInt(5) + Unit + } + is UniffiBackupStatus.Failed -> { + buf.putInt(6) + FfiConverterString.write(value.`reason`, buf) + Unit + } + }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } + } +} + + + + + /** @@ -8536,6 +8755,50 @@ sealed class UniffiIntent { object ResetPairing : UniffiIntent() + /** + * Turn the config backup on with this relay (`wss://`), and look on it + * for a backup this identity already saved. + */ + data class SetBackupRelay( + val `url`: kotlin.String) : UniffiIntent() + + { + + + companion object + } + + /** + * Merge the backup found on the relay into this phone. + */ + object ImportBackup : UniffiIntent() + + + /** + * Keep this phone's configuration over the backup found on the relay; + * it replaces that backup. + */ + object KeepLocalConfig : UniffiIntent() + + + /** + * Save the backup now, even if nothing changed. + */ + object BackupNow : UniffiIntent() + + + /** + * Turn the backup off; with `delete`, also ask the relay to delete it. + */ + data class DisableBackup( + val `delete`: kotlin.Boolean) : UniffiIntent() + + { + + + companion object + } + @@ -8774,6 +9037,15 @@ public object FfiConverterTypeUniffiIntent : FfiConverterRustBuffer UniffiIntent.DismissStagedPairing 51 -> UniffiIntent.ResetPairing + 52 -> UniffiIntent.SetBackupRelay( + FfiConverterString.read(buf), + ) + 53 -> UniffiIntent.ImportBackup + 54 -> UniffiIntent.KeepLocalConfig + 55 -> UniffiIntent.BackupNow + 56 -> UniffiIntent.DisableBackup( + FfiConverterBoolean.read(buf), + ) else -> throw RuntimeException("invalid enum value, something is very wrong!!") } } @@ -9208,6 +9480,38 @@ public object FfiConverterTypeUniffiIntent : FfiConverterRustBuffer { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterString.allocationSize(value.`url`) + ) + } + is UniffiIntent.ImportBackup -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + is UniffiIntent.KeepLocalConfig -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + is UniffiIntent.BackupNow -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + is UniffiIntent.DisableBackup -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterBoolean.allocationSize(value.`delete`) + ) + } } override fun write(value: UniffiIntent, buf: ByteBuffer) { @@ -9539,6 +9843,28 @@ public object FfiConverterTypeUniffiIntent : FfiConverterRustBuffer { + buf.putInt(52) + FfiConverterString.write(value.`url`, buf) + Unit + } + is UniffiIntent.ImportBackup -> { + buf.putInt(53) + Unit + } + is UniffiIntent.KeepLocalConfig -> { + buf.putInt(54) + Unit + } + is UniffiIntent.BackupNow -> { + buf.putInt(55) + Unit + } + is UniffiIntent.DisableBackup -> { + buf.putInt(56) + FfiConverterBoolean.write(value.`delete`, buf) + Unit + } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } } } @@ -11649,6 +11975,22 @@ public object FfiConverterMapStringSequenceString: FfiConverterRustBuffer + UniffiLib.uniffi_client_ffi_fn_func_nsec_of( + + + FfiConverterString.lower(`secretHex`),_status) +} + ) + } + + /** * Whether Orbot routing was on when settings were last saved, for * [`Core::new`]'s `tor` argument. Pure read, safe before any `Core` exists: diff --git a/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignFixtures.kt b/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignFixtures.kt index a196a84f..fc40afcd 100644 --- a/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignFixtures.kt +++ b/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignFixtures.kt @@ -19,6 +19,8 @@ import uniffi.client_ffi.UniffiAgentDefaults import uniffi.client_ffi.UniffiAgentModels import uniffi.client_ffi.UniffiAgentPlugins import uniffi.client_ffi.UniffiAvailablePlugin +import uniffi.client_ffi.UniffiBackupStatus +import uniffi.client_ffi.UniffiBackupView import uniffi.client_ffi.UniffiInstalledPlugin import uniffi.client_ffi.UniffiAgentMcp import uniffi.client_ffi.UniffiMcpImport @@ -188,6 +190,7 @@ internal object DesignFixtures { val settings = UniffiSettingsView( uiScale = 1.0, stayConnected = true, torProxyEnabled = false, blossomServer = "", maxUploadBytes = 5_250_000uL, notificationsEnabled = true, showUsageBadge = true, showCommitBadge = true, + backup = UniffiBackupView(relay = null, savedAt = null, status = UniffiBackupStatus.Idle), ) val quickPrompts = listOf( diff --git a/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt b/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt index e25753fb..a9737c8b 100644 --- a/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt +++ b/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt @@ -29,6 +29,11 @@ import com.codedeck.plus.ui.screens.NotificationsPage import com.codedeck.plus.ui.screens.PairingBody import com.codedeck.plus.ui.screens.PluginsContent import com.codedeck.plus.ui.screens.UploadsPage +import com.codedeck.plus.ui.screens.AccountPage +import com.codedeck.plus.ui.screens.BackupPage +import com.codedeck.plus.ui.screens.RestoreContent +import uniffi.client_ffi.UniffiBackupStatus +import uniffi.client_ffi.UniffiBackupView import com.codedeck.plus.ui.screens.SettingsHub import com.codedeck.plus.ui.session.Composer import com.codedeck.plus.ui.session.QuickPromptStrip @@ -180,6 +185,34 @@ private val pages: Map Unit> = linkedMapOf( "settings_uploads_blossom" to { UploadsPage(settings.copy(blossomServer = "https://blossom.example.com", maxUploadBytes = 26_214_400uL), {}, {}) }, + "settings_backup_off" to { BackupPage(settings.backup, torOn = true, now = NOW, dispatch = {}, onBack = {}) }, + "settings_backup_on" to { + BackupPage( + UniffiBackupView(relay = "wss://relay.example.org", savedAt = (NOW - 3 * 60_000).toULong(), status = UniffiBackupStatus.Idle), + torOn = false, now = NOW, dispatch = {}, onBack = {}, + ) + }, + "settings_backup_found" to { + BackupPage( + UniffiBackupView(relay = "wss://relay.example.org", savedAt = null, status = FOUND), + torOn = false, now = NOW, dispatch = {}, onBack = {}, + ) + }, + "settings_backup_failed" to { + BackupPage( + UniffiBackupView( + relay = "wss://relay.example.org", + savedAt = (NOW - 26 * 3_600_000).toULong(), + status = UniffiBackupStatus.Failed("The relay did not take the backup: blocked: auth required"), + ), + torOn = false, now = NOW, dispatch = {}, onBack = {}, + ) + }, + "settings_account_key" to { AccountPage(workstation.npub, signerLabel = null, onLogOut = {}, onBack = {}, revealKey = { null }) }, + "restore" to { RestoreContent(settings.backup, torOn = false, dispatch = {}, onDone = {}, now = NOW) }, + "restore_found" to { + RestoreContent(UniffiBackupView(relay = "wss://relay.example.org", savedAt = null, status = FOUND), torOn = false, dispatch = {}, onDone = {}, now = NOW) + }, "logs" to { LogsContent( lines = listOf( @@ -196,6 +229,9 @@ private val pages: Map Unit> = linkedMapOf( }, ) +/** A backup another phone saved two hours before the fixtures' clock. */ +private val FOUND = UniffiBackupStatus.Found(savedAt = (NOW - 2 * 3_600_000).toULong(), machines = 2u) + private fun Paparazzi.page(name: String) = snapshot { CodeDeckTheme { pages.getValue(name)() } } /** The pages on a phone: what fits on one screen. */ @@ -230,6 +266,13 @@ class DesignSnapshotTest { @Test fun settings_connection() = paparazzi.page("settings_connection") @Test fun settings_uploads() = paparazzi.page("settings_uploads") @Test fun settings_uploads_blossom() = paparazzi.page("settings_uploads_blossom") + @Test fun settings_backup_off() = paparazzi.page("settings_backup_off") + @Test fun settings_backup_on() = paparazzi.page("settings_backup_on") + @Test fun settings_backup_found() = paparazzi.page("settings_backup_found") + @Test fun settings_backup_failed() = paparazzi.page("settings_backup_failed") + @Test fun settings_account_key() = paparazzi.page("settings_account_key") + @Test fun restore() = paparazzi.page("restore") + @Test fun restore_found() = paparazzi.page("restore_found") @Test fun logs() = paparazzi.page("logs") } diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_settings.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_settings.png index 4a65ea0c..36eb9b84 100644 Binary files a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_settings.png and b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_settings.png differ diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_restore.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_restore.png new file mode 100644 index 00000000..cd8e4877 Binary files /dev/null and b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_restore.png differ diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_restore_found.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_restore_found.png new file mode 100644 index 00000000..c25e6600 Binary files /dev/null and b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_restore_found.png differ diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_settings_account_key.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_settings_account_key.png new file mode 100644 index 00000000..16877b06 Binary files /dev/null and b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_settings_account_key.png differ diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_settings_backup_failed.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_settings_backup_failed.png new file mode 100644 index 00000000..46d0c3ec Binary files /dev/null and b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_settings_backup_failed.png differ diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_settings_backup_found.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_settings_backup_found.png new file mode 100644 index 00000000..ca9cbd3e Binary files /dev/null and b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_settings_backup_found.png differ diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_settings_backup_off.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_settings_backup_off.png new file mode 100644 index 00000000..872aff65 Binary files /dev/null and b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_settings_backup_off.png differ diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_settings_backup_on.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_settings_backup_on.png new file mode 100644 index 00000000..009a25b0 Binary files /dev/null and b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_settings_backup_on.png differ diff --git a/crates/bridge-runtime/src/relay.rs b/crates/bridge-runtime/src/relay.rs index 53275942..38f86663 100644 --- a/crates/bridge-runtime/src/relay.rs +++ b/crates/bridge-runtime/src/relay.rs @@ -485,6 +485,7 @@ impl Relays { kinds: vec![COMMAND_KIND], authors, p_tags: vec![self.bridge_pubkey.clone()], + d_tags: vec![], since: Some(since as i64), }; self.commands = Some(self.subscribe(filter, Via::Commands)); @@ -497,6 +498,7 @@ impl Relays { kinds: vec![COMMAND_KIND], authors: vec![], p_tags: vec![self.bridge_pubkey.clone()], + d_tags: vec![], since: Some(since as i64), }; self.pairing = Some(self.subscribe(filter, Via::Pairing)); diff --git a/crates/client-core/src/stores/backup.rs b/crates/client-core/src/stores/backup.rs new file mode 100644 index 00000000..2dd94780 --- /dev/null +++ b/crates/client-core/src/stores/backup.rs @@ -0,0 +1,431 @@ +//! `backup` — the phone's configuration as one encrypted Nostr event on a +//! relay the user picks, so a new phone (or a reinstalled one) with the same +//! identity picks up where the old one left off. +//! +//! A bridge knows a phone by its identity pubkey, so what a restored phone +//! needs is only what the phone alone knows: each paired machine's relays, +//! label, direct endpoints and new-session defaults, the settings and the +//! quick prompts. Everything a bridge re-sends in its heartbeat (sessions, +//! agents, credentials, models, folders) is left out, as is anything about +//! one device. +//! +//! The session keys go in too, with each machine's confirmed grant: a +//! restored phone then reads what its bridges encrypt to those keys at once, +//! instead of asking the identity's signer to grant every bridge again. A +//! session key never signs, so whoever reads it can read payloads but never +//! command a bridge; and the backup is readable only with the identity, +//! which reads every payload anyway. The phone adopts the backup's keys only +//! while it has granted none of its own ([`merge_backup`]). +//! +//! The event is NIP-78 application data: kind [`BACKUP_KIND`], addressable, +//! so a relay keeps only the latest one per `d` tag. The `d` tag +//! ([`backup_d_tag`]) is a hash of the identity and a context only this app +//! uses: it names no app to someone browsing the relay, and no other app's +//! data can share it. The content is the [`ConfigBackup`] JSON, NIP-44 +//! encrypted to the identity itself. What stays public is what any event +//! shows: the pubkey, the kind and when it was saved. +//! +//! Pure: building, decoding and merging a backup. Encrypting, signing and +//! the relay are the runtime's. + +use nostr::hashes::{sha256, Hash}; +use serde::{Deserialize, Serialize}; + +use super::machines::{AgentDefaults, MachinesState}; +use super::pairing::is_relay_url; +use super::quick_prompts::{QuickPrompt, QuickPromptsState}; +use super::session_key::{SessionGrant, SessionKeyRing, StoredRing}; +use super::settings::{clamp_ui_scale, SettingsData, SettingsState}; + +use std::collections::BTreeMap; + +/// NIP-78 application-specific data (addressable). +pub const BACKUP_KIND: u16 = 30078; +/// NIP-09 deletion. +pub const DELETION_KIND: u16 = 5; +/// The payload version this build writes and reads. +pub const BACKUP_VERSION: u32 = 1; +/// Hashed with the identity into the `d` tag; changing it moves every +/// backup to a new address. +const D_TAG_CONTEXT: &str = "codedeck-plus/config-backup/v1"; +/// Where the backup relay is kept on the phone (never in the backup). +pub const BACKUP_STORAGE_KEY: &str = "backup"; + +/// The backup's `d` tag for identity `pubkey_hex`: hex sha256 of the +/// context, a colon and the pubkey (lowercase). Deterministic, so a new phone +/// finds it with the identity alone. +pub fn backup_d_tag(pubkey_hex: &str) -> String { + let input = format!("{D_TAG_CONTEXT}:{}", pubkey_hex.to_ascii_lowercase()); + sha256::Hash::hash(input.as_bytes()).to_string() +} + +/// The `a` address a NIP-09 deletion of the backup names. +pub fn backup_address(pubkey_hex: &str) -> String { + format!("{BACKUP_KIND}:{}:{}", pubkey_hex.to_ascii_lowercase(), backup_d_tag(pubkey_hex)) +} + +/// One paired machine, as only the phone knows it. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BackupMachine { + pub pubkey_hex: String, + /// What the bridge last called itself: shown until its next heartbeat. + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + pub relays: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub direct_endpoints: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_agent: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub agent_defaults: BTreeMap, + /// The session-key grant this bridge confirmed, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_grant: Option, +} + +/// The backup's plaintext. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigBackup { + pub v: u32, + /// When it was made, in ms since the epoch. + pub saved_at: u64, + pub machines: Vec, + pub settings: SettingsData, + pub quick_prompts: Vec, + /// The phone's session keys, with their secrets and expiries. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_keys: Option, +} + +/// The backup of the current state. +pub fn build_backup( + machines: &MachinesState, + settings: &SettingsData, + prompts: &[QuickPrompt], + ring: &SessionKeyRing, + saved_at: u64, +) -> ConfigBackup { + ConfigBackup { + v: BACKUP_VERSION, + saved_at, + session_keys: Some(ring.stored()), + machines: machines + .machines + .values() + .map(|m| BackupMachine { + pubkey_hex: m.pubkey_hex.clone(), + name: m.name.clone(), + label: m.label.clone(), + relays: m.relays.clone(), + direct_endpoints: m.direct_endpoints.clone(), + default_agent: m.default_agent.clone(), + agent_defaults: m.agent_defaults.clone(), + session_grant: m.session_grant.clone(), + }) + .collect(), + settings: settings.clone(), + quick_prompts: prompts.to_vec(), + } +} + +pub fn encode_backup(backup: &ConfigBackup) -> String { + serde_json::to_string(backup).expect("ConfigBackup serializes") +} + +/// What the backup holds, whenever it was made: two backups with the same +/// fingerprint need not both be saved. +pub fn backup_fingerprint(backup: &ConfigBackup) -> String { + let content = ConfigBackup { saved_at: 0, ..backup.clone() }; + sha256::Hash::hash(encode_backup(&content).as_bytes()).to_string() +} + +/// Decode a backup's plaintext. Total: bad JSON, another version, or a +/// machine with no relay the phone may dial is an error to show, never a +/// panic. A machine's relays the phone may not dial are dropped. +pub fn decode_backup(json: &str) -> Result { + let raw: serde_json::Value = serde_json::from_str(json).map_err(|_| "The backup could not be read.".to_string())?; + match raw.get("v").and_then(serde_json::Value::as_u64) { + Some(v) if v == u64::from(BACKUP_VERSION) => {} + Some(v) if v > u64::from(BACKUP_VERSION) => { + return Err("The backup was made by a newer version of the app. Update it and try again.".into()) + } + _ => return Err("The backup could not be read.".into()), + } + let mut backup: ConfigBackup = + serde_json::from_value(raw).map_err(|_| "The backup could not be read.".to_string())?; + backup.settings.ui_scale = clamp_ui_scale(backup.settings.ui_scale); + for m in &mut backup.machines { + m.relays.retain(|r| is_relay_url(r)); + } + backup.machines.retain(|m| !m.pubkey_hex.is_empty() && !m.relays.is_empty()); + Ok(backup) +} + +/// What an import did. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct ImportSummary { + /// Machines the phone did not have. + pub added: usize, + /// Machines it had, left as they were. + pub kept: usize, + /// Whether the phone took the backup's session keys (the host must + /// store the ring again and encrypt under it). + pub adopted_keys: bool, +} + +/// Merge `backup` into the phone: the machines it does not have are added +/// (as they were, offline until each bridge is heard from), the ones it has +/// keep their own configuration, and the settings and quick prompts become +/// the backup's. +/// +/// The backup's session keys replace `ring` only while no bridge holds or +/// awaits a grant of the phone's own (a phone that has granted its keys +/// would otherwise strand those bridges on a key it no longer has), and +/// only if they are still live. A machine then gets back the grant it had +/// confirmed, if it names a live key of the ring the phone ends up with; +/// any other is granted afresh when next heard from. +pub fn merge_backup( + backup: &ConfigBackup, + machines: &mut MachinesState, + settings: &mut SettingsState, + prompts: &mut QuickPromptsState, + ring: &mut SessionKeyRing, + now_ms: u64, +) -> ImportSummary { + let mut summary = ImportSummary::default(); + let granted = machines.machines.values().any(|m| m.session_grant.is_some() || m.session_grant_sent.is_some()); + if !granted { + if let Some(theirs) = backup.session_keys.as_ref().and_then(|s| SessionKeyRing::from_stored(s, now_ms)) { + *ring = theirs; + summary.adopted_keys = true; + } + } + for m in &backup.machines { + if machines.machine(&m.pubkey_hex).is_some() { + summary.kept += 1; + } else { + machines.register_machine(&m.pubkey_hex, &m.name, m.label.clone(), None, &m.relays); + machines.set_direct_endpoints(&m.pubkey_hex, m.direct_endpoints.clone()); + machines.set_default_agent(&m.pubkey_hex, m.default_agent.clone()); + for (agent, defaults) in &m.agent_defaults { + machines.set_agent_defaults(&m.pubkey_hex, agent, defaults.clone()); + } + summary.added += 1; + } + let grant = m.session_grant.as_ref().filter(|g| g.expires_at > now_ms / 1000 && ring.key(&g.pubkey_hex, now_ms).is_some()); + if let (Some(grant), Some(here)) = (grant, machines.machines.get_mut(&m.pubkey_hex)) { + if here.session_grant.is_none() && here.session_grant_sent.is_none() { + here.session_grant = Some(grant.clone()); + } + } + } + settings.data = backup.settings.clone(); + prompts.prompts = backup.quick_prompts.clone(); + summary +} + +/// The backup relay, kept on the phone apart from the backup. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BackupConfig { + /// `None`: backup is off. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub relay: Option, + /// When a backup last reached the relay, in ms since the epoch. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub saved_at: Option, + /// The fingerprint of what was last saved. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fingerprint: Option, +} + +/// Tolerant hydrate: anything unreadable is backup off. +pub fn hydrate_backup_config(raw: Option<&str>) -> BackupConfig { + let config: BackupConfig = raw.and_then(|r| serde_json::from_str(r).ok()).unwrap_or_default(); + match &config.relay { + Some(relay) if !is_relay_url(relay) => BackupConfig::default(), + _ => config, + } +} + +pub fn serialize_backup_config(config: &BackupConfig) -> String { + serde_json::to_string(config).expect("BackupConfig serializes") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::stores::machines::MergeOptions; + use crate::stores::settings::default_settings; + + const ID: &str = "AB12cd"; + const NOW: u64 = 1_700_000_000_000; + + fn ring() -> SessionKeyRing { + SessionKeyRing::load(None, NOW).0 + } + + fn phone() -> (MachinesState, SettingsState, QuickPromptsState) { + let mut machines = MachinesState::new(BTreeMap::new(), MergeOptions::default()); + machines.register_machine("m1", "laptop", Some("Work".into()), None, &["wss://relay.one".to_string()]); + machines.set_direct_endpoints("m1", vec!["wss://laptop.tailnet:7447".into()]); + machines.set_default_agent("m1", Some("opencode".into())); + machines.set_agent_defaults("m1", "claude", AgentDefaults { mode: "plan".into(), effort: String::new(), model: String::new() }); + let mut settings = SettingsState::default(); + settings.set_tor_proxy_enabled(true); + let prompts = QuickPromptsState::from_hydrated(vec![QuickPrompt { id: "q".into(), label: "Go".into(), text: "Continue".into() }]); + (machines, settings, prompts) + } + + #[test] + fn the_d_tag_is_a_hash_of_the_identity_and_names_no_app() { + let tag = backup_d_tag(ID); + assert_eq!(tag.len(), 64); + assert!(tag.chars().all(|c| c.is_ascii_hexdigit())); + assert!(!tag.contains("codedeck")); + // The same for the same identity, whatever its case; another for another. + assert_eq!(tag, backup_d_tag(&ID.to_ascii_lowercase())); + assert_ne!(tag, backup_d_tag("ab12ce")); + assert_eq!(backup_address(ID), format!("30078:ab12cd:{tag}")); + } + + #[test] + fn a_backup_holds_only_what_the_phone_alone_knows_and_round_trips() { + let (mut machines, settings, prompts) = phone(); + // A heartbeat's worth of data the bridge would send again. + machines.machines.get_mut("m1").unwrap().folders = vec!["repo".into()]; + let backup = build_backup(&machines, &settings.data, &prompts.prompts, &ring(), 7); + let json = encode_backup(&backup); + assert!(!json.contains("repo") && !json.contains("sessions")); + assert_eq!(decode_backup(&json).unwrap(), backup); + } + + #[test] + fn the_fingerprint_ignores_when_it_was_made() { + let (machines, settings, prompts) = phone(); + let keys = ring(); + let a = build_backup(&machines, &settings.data, &prompts.prompts, &keys, 1); + let b = build_backup(&machines, &settings.data, &prompts.prompts, &keys, 2); + assert_eq!(backup_fingerprint(&a), backup_fingerprint(&b)); + let mut c = b.clone(); + c.quick_prompts.clear(); + assert_ne!(backup_fingerprint(&a), backup_fingerprint(&c)); + } + + #[test] + fn importing_adds_the_missing_machines_and_keeps_the_ones_the_phone_has() { + let (machines, settings, prompts) = phone(); + let mut backup = build_backup(&machines, &settings.data, &prompts.prompts, &ring(), 1); + backup.machines.push(BackupMachine { + pubkey_hex: "m2".into(), + name: "vps".into(), + label: None, + relays: vec!["wss://relay.two".into()], + direct_endpoints: vec![], + default_agent: None, + agent_defaults: BTreeMap::new(), + session_grant: None, + }); + + // A phone that already has m1, labelled its own way. + let mut here = MachinesState::new(BTreeMap::new(), MergeOptions::default()); + here.register_machine("m1", "laptop", Some("Mine".into()), None, &["wss://relay.local".to_string()]); + let mut here_settings = SettingsState::new(default_settings()); + let mut here_prompts = QuickPromptsState::default(); + + let summary = merge_backup(&backup, &mut here, &mut here_settings, &mut here_prompts, &mut ring(), NOW); + assert_eq!(summary, ImportSummary { added: 1, kept: 1, adopted_keys: true }); + assert_eq!(here.machine("m1").unwrap().label.as_deref(), Some("Mine")); + assert_eq!(here.machine("m2").unwrap().relays, ["wss://relay.two"]); + assert!(here_settings.data.tor_proxy_enabled); + assert_eq!(here_prompts.prompts.len(), 1); + + // Everything a fresh phone gets back. + let mut fresh = MachinesState::new(BTreeMap::new(), MergeOptions::default()); + merge_backup(&backup, &mut fresh, &mut SettingsState::default(), &mut QuickPromptsState::default(), &mut ring(), NOW); + let m1 = fresh.machine("m1").unwrap(); + assert_eq!((m1.label.as_deref(), m1.default_agent.as_deref()), (Some("Work"), Some("opencode"))); + assert_eq!(m1.direct_endpoints, ["wss://laptop.tailnet:7447"]); + assert_eq!(m1.agent_defaults["claude"].mode, "plan"); + } + + #[test] + fn a_bad_or_newer_backup_is_an_error_not_a_panic() { + assert!(decode_backup("not json").is_err()); + assert!(decode_backup(r#"{"v":1}"#).is_err()); + assert!(decode_backup(r#"{"v":99}"#).unwrap_err().contains("newer version")); + // A machine with no relay the phone may dial is dropped. + let (machines, settings, prompts) = phone(); + let mut backup = build_backup(&machines, &settings.data, &prompts.prompts, &ring(), 1); + backup.machines[0].relays = vec!["http://plain.example".into()]; + assert!(decode_backup(&encode_backup(&backup)).unwrap().machines.is_empty()); + } + + fn grant(key: &SessionKeyRing) -> SessionGrant { + SessionGrant { pubkey_hex: key.current.pubkey_hex().to_string(), expires_at: key.current.expires_at, sent_at: NOW / 1000 } + } + + #[test] + fn a_fresh_phone_takes_the_session_keys_and_the_confirmed_grants() { + let (mut machines, settings, prompts) = phone(); + let theirs = ring(); + machines.machines.get_mut("m1").unwrap().session_grant = Some(grant(&theirs)); + let backup = build_backup(&machines, &settings.data, &prompts.prompts, &theirs, 1); + // The secrets travel only inside the encrypted content, and no Debug shows them. + let secret = theirs.current.keypair.secret_hex(); + assert!(encode_backup(&backup).contains(&secret)); + assert!(!format!("{backup:?}").contains(&secret)); + let backup = decode_backup(&encode_backup(&backup)).unwrap(); + + let mut fresh = MachinesState::new(BTreeMap::new(), MergeOptions::default()); + let mut mine = ring(); + let summary = merge_backup(&backup, &mut fresh, &mut SettingsState::default(), &mut QuickPromptsState::default(), &mut mine, NOW); + assert!(summary.adopted_keys); + assert_eq!(mine.current.keypair.secret_hex(), secret); + assert_eq!(fresh.session_key_of("m1", NOW), Some(theirs.current.pubkey_hex())); + } + + #[test] + fn a_phone_that_granted_its_own_keys_keeps_them() { + let (mut machines, settings, prompts) = phone(); + let theirs = ring(); + machines.machines.get_mut("m1").unwrap().session_grant = Some(grant(&theirs)); + let backup = build_backup(&machines, &settings.data, &prompts.prompts, &theirs, 1); + + let mut here = MachinesState::new(BTreeMap::new(), MergeOptions::default()); + here.register_machine("m0", "desk", None, None, &["wss://relay.local".to_string()]); + let mut mine = ring(); + here.machines.get_mut("m0").unwrap().session_grant_sent = Some(grant(&mine)); + let own = mine.current.pubkey_hex().to_string(); + let summary = merge_backup(&backup, &mut here, &mut SettingsState::default(), &mut QuickPromptsState::default(), &mut mine, NOW); + assert!(!summary.adopted_keys); + assert_eq!(mine.current.pubkey_hex(), own); + // m1's grant names a key this phone does not hold: it is granted afresh. + assert!(here.machine("m1").unwrap().session_grant.is_none()); + } + + #[test] + fn lapsed_session_keys_are_not_taken() { + let (machines, settings, prompts) = phone(); + let theirs = ring(); + let backup = build_backup(&machines, &settings.data, &prompts.prompts, &theirs, 1); + let later = (theirs.current.expires_at + 1) * 1000; + let mut fresh = MachinesState::new(BTreeMap::new(), MergeOptions::default()); + let mut mine = SessionKeyRing::load(None, later).0; + let summary = merge_backup(&backup, &mut fresh, &mut SettingsState::default(), &mut QuickPromptsState::default(), &mut mine, later); + assert!(!summary.adopted_keys); + assert_ne!(mine.current.pubkey_hex(), theirs.current.pubkey_hex()); + } + + #[test] + fn the_backup_relay_hydrates_tolerantly() { + assert_eq!(hydrate_backup_config(None), BackupConfig::default()); + assert_eq!(hydrate_backup_config(Some("garbage")), BackupConfig::default()); + assert_eq!(hydrate_backup_config(Some(r#"{"relay":"http://x"}"#)), BackupConfig::default()); + let config = BackupConfig { relay: Some("wss://r".into()), saved_at: Some(5), fingerprint: None }; + assert_eq!(hydrate_backup_config(Some(&serialize_backup_config(&config))), config); + } +} diff --git a/crates/client-core/src/stores/mod.rs b/crates/client-core/src/stores/mod.rs index dbce42ae..3be49514 100644 --- a/crates/client-core/src/stores/mod.rs +++ b/crates/client-core/src/stores/mod.rs @@ -1,6 +1,7 @@ //! stores — domain state as pure state machines. No zustand, no //! I/O; the runtime owns wiring + persistence. +pub mod backup; pub mod session_key; pub mod fetches; pub mod machines; diff --git a/crates/client-core/src/stores/session_key.rs b/crates/client-core/src/stores/session_key.rs index 368a013b..ad2b584a 100644 --- a/crates/client-core/src/stores/session_key.rs +++ b/crates/client-core/src/stores/session_key.rs @@ -84,22 +84,32 @@ pub struct SessionKeyRing { pub previous: Option, } -/// The ring as the host stores it. Holds secrets. -#[derive(Serialize, Deserialize)] +/// The ring as the host stores it, and as the config backup carries it. +/// Holds secrets; its `Debug` shows none. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -struct StoredRing { +pub struct StoredRing { current: StoredKey, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] previous: Option, } -#[derive(Serialize, Deserialize)] +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct StoredKey { secret_hex: String, expires_at: u64, } +impl std::fmt::Debug for StoredRing { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StoredRing") + .field("expires_at", &self.current.expires_at) + .field("previous", &self.previous.is_some()) + .finish_non_exhaustive() + } +} + impl StoredKey { fn of(key: &SessionKey) -> Self { Self { secret_hex: key.keypair.secret_hex(), expires_at: key.expires_at } @@ -133,8 +143,21 @@ impl SessionKeyRing { /// The ring as the host stores it. Holds the secrets: keep it wherever /// the host keeps secrets, never in a log. pub fn encode(&self) -> String { - let ring = StoredRing { current: StoredKey::of(&self.current), previous: self.previous.as_ref().map(StoredKey::of) }; - serde_json::to_string(&ring).expect("ring serializes") + serde_json::to_string(&self.stored()).expect("ring serializes") + } + + /// The ring with its secrets, as the config backup carries it. + pub fn stored(&self) -> StoredRing { + StoredRing { current: StoredKey::of(&self.current), previous: self.previous.as_ref().map(StoredKey::of) } + } + + /// The ring a backup carried, if its current key is readable and still + /// live at `now_ms`; a previous key already lapsed is left behind. + pub fn from_stored(stored: &StoredRing, now_ms: u64) -> Option { + let now = now_ms / 1000; + let current = stored.current.key().filter(|k| k.live(now))?; + let previous = stored.previous.as_ref().and_then(StoredKey::key).filter(|k| k.live(now)); + Some(Self { current, previous }) } /// Replace the current key with a fresh one once less than diff --git a/crates/client-ffi/src/intent.rs b/crates/client-ffi/src/intent.rs index 4d440d60..6ac21931 100644 --- a/crates/client-ffi/src/intent.rs +++ b/crates/client-ffi/src/intent.rs @@ -401,6 +401,22 @@ pub enum UniffiIntent { }, DismissStagedPairing, ResetPairing, + /// Turn the config backup on with this relay (`wss://`), and look on it + /// for a backup this identity already saved. + SetBackupRelay { + url: String, + }, + /// Merge the backup found on the relay into this phone. + ImportBackup, + /// Keep this phone's configuration over the backup found on the relay; + /// it replaces that backup. + KeepLocalConfig, + /// Save the backup now, even if nothing changed. + BackupNow, + /// Turn the backup off; with `delete`, also ask the relay to delete it. + DisableBackup { + delete: bool, + }, } #[derive(Debug, thiserror::Error, uniffi::Error)] @@ -573,6 +589,11 @@ impl TryFrom for Intent { UniffiIntent::ConfirmStagedPairing { label } => Intent::ConfirmStagedPairing { label }, UniffiIntent::DismissStagedPairing => Intent::DismissStagedPairing, UniffiIntent::ResetPairing => Intent::ResetPairing, + UniffiIntent::SetBackupRelay { url } => Intent::SetBackupRelay(url), + UniffiIntent::ImportBackup => Intent::ImportBackup, + UniffiIntent::KeepLocalConfig => Intent::KeepLocalConfig, + UniffiIntent::BackupNow => Intent::BackupNow, + UniffiIntent::DisableBackup { delete } => Intent::DisableBackup { delete }, }) } } diff --git a/crates/client-ffi/src/lib.rs b/crates/client-ffi/src/lib.rs index ad697713..e917a478 100644 --- a/crates/client-ffi/src/lib.rs +++ b/crates/client-ffi/src/lib.rs @@ -95,6 +95,14 @@ fn is_relay_url(url: String) -> bool { client_runtime::client_core::stores::pairing::is_relay_url(url.trim()) } +/// The `nsec…` of an on-device login's hex secret, for "Show my key"; `None` +/// for anything that is not a secret key. A secret: the host shows it only +/// on the user's request and never logs it. +#[uniffi::export] +fn nsec_of(secret_hex: String) -> Option { + protocol::crypto::nsec_from_secret_hex(secret_hex.trim()).ok() +} + /// Whether Orbot routing was on when settings were last saved, for /// [`Core::new`]'s `tor` argument. Pure read, safe before any `Core` exists: /// the proxy must be known before the first relay or Blossom connection, diff --git a/crates/client-ffi/src/views.rs b/crates/client-ffi/src/views.rs index 3d830ad0..b65a20ff 100644 --- a/crates/client-ffi/src/views.rs +++ b/crates/client-ffi/src/views.rs @@ -949,10 +949,47 @@ pub struct UniffiSettingsView { pub notifications_enabled: bool, pub show_usage_badge: bool, pub show_commit_badge: bool, + pub backup: UniffiBackupView, +} + +/// The config backup, as the settings page shows it. +#[derive(Debug, Clone, uniffi::Record)] +pub struct UniffiBackupView { + /// `None`: backup is off. + pub relay: Option, + /// When a backup last reached the relay (ms since the epoch). + pub saved_at: Option, + pub status: UniffiBackupStatus, +} + +#[derive(Debug, Clone, uniffi::Enum)] +pub enum UniffiBackupStatus { + /// Nothing going on (off, or on and up to date). + Idle, + /// Looking on the relay for a backup. + Checking, + /// A backup is on the relay: import it, or keep this phone's. + Found { saved_at: u64, machines: u32 }, + Saving, + Importing, + /// The last operation failed; `reason` is for the user. + Failed { reason: String }, +} + +fn build_uniffi_backup_status(s: &client_runtime::backup::BackupStatus) -> UniffiBackupStatus { + use client_runtime::backup::BackupStatus; + match s { + BackupStatus::Idle => UniffiBackupStatus::Idle, + BackupStatus::Checking => UniffiBackupStatus::Checking, + BackupStatus::Found { saved_at, machines } => UniffiBackupStatus::Found { saved_at: *saved_at, machines: *machines }, + BackupStatus::Saving => UniffiBackupStatus::Saving, + BackupStatus::Importing => UniffiBackupStatus::Importing, + BackupStatus::Failed { reason } => UniffiBackupStatus::Failed { reason: reason.clone() }, + } } pub fn build_uniffi_settings_view(v: &SettingsView) -> UniffiSettingsView { - let d = &v.0; + let d = &v.data; UniffiSettingsView { ui_scale: d.ui_scale, stay_connected: d.stay_connected, @@ -962,6 +999,11 @@ pub fn build_uniffi_settings_view(v: &SettingsView) -> UniffiSettingsView { notifications_enabled: d.notifications_enabled, show_usage_badge: d.show_usage_badge, show_commit_badge: d.show_commit_badge, + backup: UniffiBackupView { + relay: v.backup.relay.clone(), + saved_at: v.backup.saved_at, + status: build_uniffi_backup_status(&v.backup.status), + }, } } diff --git a/crates/client-runtime/src/backup.rs b/crates/client-runtime/src/backup.rs new file mode 100644 index 00000000..2a754ed3 --- /dev/null +++ b/crates/client-runtime/src/backup.rs @@ -0,0 +1,168 @@ +//! The config backup's events: sealing the phone's configuration into one +//! (NIP-44 encrypted to the identity itself, then signed), opening one, and +//! the deletion that withdraws it. What goes in the backup, and merging it +//! back, is `client_core::stores::backup`; when to save and fetch is the +//! runtime loop's. +//! +//! Only the identity can read a backup: its content is NIP-44 encrypted from +//! the identity to the identity, through the identity's signer (so a key held +//! by a NIP-55 signer app works the same way). The relay sees the pubkey, +//! the kind, when it was saved and an opaque `d` tag — nothing of what is in +//! it. + +use client_core::stores::backup::{ + backup_address, backup_d_tag, decode_backup, encode_backup, ConfigBackup, BACKUP_KIND, DELETION_KIND, +}; +use nostr::{EventBuilder, Kind, PublicKey, Tag, TagKind, Timestamp, UnsignedEvent}; +use nostr_transport::NostrEvent; +use protocol::nostr_event::SignedEvent; + +use crate::signer::{sign, IdentitySigner}; + +/// Where a backup operation stands, as the settings page shows it. +#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, specta::Type)] +#[serde(rename_all = "camelCase", tag = "state")] +pub enum BackupStatus { + /// Nothing going on (backup off, or on and up to date). + #[default] + Idle, + /// Looking on the relay for a backup. + Checking, + /// A backup is on the relay: import it, or keep this phone's. + Found { + #[specta(type = specta_typescript::Number)] + saved_at: u64, + machines: u32, + }, + Saving, + Importing, + /// The last operation failed; `reason` says why, for the user. + Failed { reason: String }, +} + +fn unsigned(signer: &dyn IdentitySigner, kind: u16, content: String, tags: Vec, created_at: u64) -> Result { + let author = PublicKey::from_hex(&signer.pubkey_hex()).map_err(|e| e.to_string())?; + let mut event = EventBuilder::new(Kind::Custom(kind), content) + .tags(tags) + .custom_created_at(Timestamp::from(created_at)) + .build(author); + event.ensure_id(); + Ok(event) +} + +/// `backup` as a signed event, dated `created_at` (seconds): its content +/// encrypted to the identity itself. +pub async fn seal_backup(signer: &dyn IdentitySigner, backup: &ConfigBackup, created_at: u64) -> Result { + let me = signer.pubkey_hex(); + let content = signer + .nip44_encrypt(&me, &encode_backup(backup)) + .await + .map_err(|e| format!("The backup could not be encrypted: {e}"))?; + let tags = vec![Tag::identifier(backup_d_tag(&me))]; + let event = unsigned(signer, BACKUP_KIND, content, tags, created_at)?; + let signed = sign(signer, event).await.map_err(|e| format!("The backup could not be signed: {e}"))?; + Ok(SignedEvent::from_nostr(&signed)) +} + +/// The backup `event` holds. Only one the identity wrote to itself opens. +pub async fn open_backup(signer: &dyn IdentitySigner, event: &NostrEvent) -> Result { + let me = signer.pubkey_hex(); + if event.kind != BACKUP_KIND || !event.pubkey.eq_ignore_ascii_case(&me) { + return Err("The backup could not be read.".into()); + } + let plaintext = signer + .nip44_decrypt(&me, &event.content) + .await + .map_err(|_| "The backup could not be decrypted with this key.".to_string())?; + decode_backup(&plaintext) +} + +/// The newest of `events` that could be the identity's backup. +pub fn newest_backup<'a>(events: &'a [NostrEvent], me: &str) -> Option<&'a NostrEvent> { + events + .iter() + .filter(|e| e.kind == BACKUP_KIND && e.pubkey.eq_ignore_ascii_case(me)) + .max_by_key(|e| e.created_at) +} + +/// A NIP-09 deletion of the identity's backup, dated `created_at`. +pub async fn seal_deletion(signer: &dyn IdentitySigner, created_at: u64) -> Result { + let me = signer.pubkey_hex(); + let tags = vec![ + Tag::custom(TagKind::a(), [backup_address(&me)]), + Tag::custom(TagKind::k(), [BACKUP_KIND.to_string()]), + ]; + let event = unsigned(signer, DELETION_KIND, String::new(), tags, created_at)?; + let signed = sign(signer, event).await.map_err(|e| e.to_string())?; + Ok(SignedEvent::from_nostr(&signed)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::signer::LocalSigner; + use client_core::stores::machines::{MachinesState, MergeOptions}; + use client_core::stores::quick_prompts::QuickPrompt; + use client_core::stores::settings::default_settings; + use protocol::crypto::generate_keypair; + use std::collections::BTreeMap; + + const MACHINE: &str = "4f3c2b1a09f8e7d6c5b4a3928170615f4e3d2c1b0a9f8e7d6c5b4a3928170615"; + const SESSION_SECRET: &str = "7e57000000000000000000000000000000000000000000000000000000000001"; + + fn backup() -> ConfigBackup { + let mut machines = MachinesState::new(BTreeMap::new(), MergeOptions::default()); + machines.register_machine(MACHINE, "my laptop", Some("Secret lab".into()), None, &["wss://private.relay".to_string()]); + let prompts = [QuickPrompt { id: "q".into(), label: "Deploy".into(), text: "ship it".into() }]; + let stored = format!(r#"{{"current":{{"secretHex":"{SESSION_SECRET}","expiresAt":99999999999}}}}"#); + let ring = client_core::stores::session_key::SessionKeyRing::load(Some(&stored), 1_000).0; + client_core::stores::backup::build_backup(&machines, &default_settings(), &prompts, &ring, 42) + } + + fn as_received(e: &SignedEvent) -> NostrEvent { + NostrEvent { id: e.id.clone(), kind: e.kind, created_at: e.created_at as i64, pubkey: e.pubkey.clone(), content: e.content.clone() } + } + + #[tokio::test] + async fn a_sealed_backup_shows_nothing_of_what_is_in_it_and_only_its_identity_opens_it() { + let me = LocalSigner(generate_keypair()); + let event = seal_backup(&me, &backup(), 1_000).await.unwrap(); + assert_eq!(event.kind, BACKUP_KIND); + assert_eq!(event.created_at, 1_000); + // The only tag is the opaque d tag. + assert_eq!(event.tags, vec![vec!["d".to_string(), backup_d_tag(&me.pubkey_hex())]]); + let wire = serde_json::to_string(&event).unwrap(); + // Each has a space or a dot, or is long: none can turn up by chance in + // base64 ciphertext or a hex id. + for secret in ["Secret lab", "private.relay", "my laptop", "ship it", MACHINE, SESSION_SECRET] { + assert!(!wire.contains(secret), "{secret} is readable in {wire}"); + } + assert_eq!(open_backup(&me, &as_received(&event)).await.unwrap(), backup()); + + // Another identity cannot read it, even handed the event as its own. + let other = LocalSigner(generate_keypair()); + let mut stolen = as_received(&event); + assert!(open_backup(&other, &stolen).await.is_err()); + stolen.pubkey = other.pubkey_hex(); + assert!(open_backup(&other, &stolen).await.is_err()); + } + + #[tokio::test] + async fn the_newest_backup_is_the_one_taken() { + let me = LocalSigner(generate_keypair()); + let old = as_received(&seal_backup(&me, &backup(), 1).await.unwrap()); + let new = as_received(&seal_backup(&me, &backup(), 2).await.unwrap()); + let events = [old, new.clone()]; + assert_eq!(newest_backup(&events, &me.pubkey_hex()), Some(&new)); + assert_eq!(newest_backup(&events, "someone-else"), None); + } + + #[tokio::test] + async fn a_deletion_names_the_backup_by_its_address() { + let me = LocalSigner(generate_keypair()); + let event = seal_deletion(&me, 5).await.unwrap(); + assert_eq!(event.kind, DELETION_KIND); + assert!(event.tags.contains(&vec!["a".to_string(), backup_address(&me.pubkey_hex())])); + assert!(event.tags.contains(&vec!["k".to_string(), "30078".to_string()])); + } +} diff --git a/crates/client-runtime/src/intent.rs b/crates/client-runtime/src/intent.rs index 8d6b0918..73755a4e 100644 --- a/crates/client-runtime/src/intent.rs +++ b/crates/client-runtime/src/intent.rs @@ -383,6 +383,20 @@ pub enum Intent { SetUiScale(f64), SetShowUsageBadge(bool), SetShowCommitBadge(bool), + + // --- config backup (the loop's, see `runtime::backup`) --- + /// Turn the backup on with this relay (or move it there), then look for + /// a backup already on it. + SetBackupRelay(String), + /// Merge the backup found on the relay into this phone. + ImportBackup, + /// Leave the backup found on the relay and save this phone's over it. + KeepLocalConfig, + /// Save the backup now. + BackupNow, + /// Turn the backup off; `delete` also asks the relay to delete it. + DisableBackup { delete: bool }, + AddQuickPrompt { id: String, label: String, @@ -864,6 +878,12 @@ pub fn apply( stores.settings.set_show_commit_badge(on); r.persist(StoreId::Settings); } + // Taken by the loop before the stores see an intent. + Intent::SetBackupRelay(_) + | Intent::ImportBackup + | Intent::KeepLocalConfig + | Intent::BackupNow + | Intent::DisableBackup { .. } => {} Intent::AddQuickPrompt { id, label, text } => { if stores.quick_prompts.add_prompt(&id, &label, &text) { r.persist(StoreId::QuickPrompts); diff --git a/crates/client-runtime/src/lib.rs b/crates/client-runtime/src/lib.rs index af6e2158..13b0b4ed 100644 --- a/crates/client-runtime/src/lib.rs +++ b/crates/client-runtime/src/lib.rs @@ -20,6 +20,7 @@ uniffi::setup_scaffolding!(); pub mod attachments; +pub mod backup; pub mod deadline; pub mod dispatch; pub mod intent; @@ -44,7 +45,7 @@ pub use intent::{Intent, IntentCtx, SessionFileSend}; pub use ports::{Kv, MemoryKv, MemoryTranscriptStore, Notifier, NullNotifier, SessionKeyStore, TranscriptStore}; pub use stores::{CoreStores, HydratedCore}; pub use view::{ - ConnectionView, MachinesView, OutboxView, PairingView, + BackupView, ConnectionView, MachinesView, OutboxView, PairingView, PendingSessionsView, QuickPromptsView, SettingsView, TranscriptRowsView, TranscriptRowView, TranscriptSyncView, UiView, }; diff --git a/crates/client-runtime/src/nostr_client.rs b/crates/client-runtime/src/nostr_client.rs index ddfe4eaa..ec04e730 100644 --- a/crates/client-runtime/src/nostr_client.rs +++ b/crates/client-runtime/src/nostr_client.rs @@ -54,6 +54,7 @@ pub fn build_phone_filters(phone_pubkey: &str, authors: &[String], last_stored_s kinds, authors: authors.to_vec(), p_tags: vec![phone_pubkey.to_string()], + d_tags: vec![], since, }; vec![ diff --git a/crates/client-runtime/src/runtime.rs b/crates/client-runtime/src/runtime.rs index 4a63082a..52da0e75 100644 --- a/crates/client-runtime/src/runtime.rs +++ b/crates/client-runtime/src/runtime.rs @@ -47,6 +47,7 @@ use crate::ports::{ }; use crate::signer::{Cipher, IdentityAuth, IdentitySigner, PhoneKeys, SignerError}; use crate::stores::{hydrate, CoreStores, Persister, StoresConfig}; +use client_core::stores::backup::ConfigBackup; use crate::transport::ws::{WsConfig, WsTransport, PING_EVERY, PUBLISH_CONFIRM_ATTEMPTS, PUBLISH_CONFIRM_BUDGET}; use crate::view::{ ConnectionView, MachinesView, OutboxView, PairingView, @@ -424,6 +425,9 @@ impl Core { last_connected_relays: Vec::new(), pair_timer: None, undo_timer: None, + backup_timer: None, + backup_busy: false, + backup_created_at: 0, machines_dirty: false, stored_seen_dirty: None, flush_timer: None, @@ -699,6 +703,15 @@ enum Msg { }, /// A bridge's direct link delivered an event. DirectEvent(NostrEvent), + /// Time to save the config backup. + BackupDue, + /// A look on the backup relay finished. + BackupFetched(backup::Fetched), + /// A backup save finished: when it was made and what it held, or why + /// it failed. + BackupSaved(Result<(u64, String), String>), + /// The backup's deletion was sent (or given up on). + BackupDeleted, /// A machine's direct link came up on an endpoint, or went down. DirectState { machine: String, @@ -721,6 +734,22 @@ enum SignerJob { }, /// Decrypt a message the session key could not. Decrypt(NostrEvent), + /// Open the identity's config backup. + OpenBackup { + event: NostrEvent, + reply: oneshot::Sender>, + }, + /// Seal the config backup into a signed event. + SealBackup { + backup: Box, + created_at: u64, + reply: oneshot::Sender>, + }, + /// Sign the deletion of the config backup. + SealDeletion { + created_at: u64, + reply: oneshot::Sender>, + }, } async fn run_signer( @@ -740,6 +769,19 @@ async fn run_signer( plaintext: signer.nip44_decrypt(&event.pubkey, &event.content).await, event, }, + // The backup's own task waits for these. + SignerJob::OpenBackup { event, reply } => { + let _ = reply.send(crate::backup::open_backup(signer.as_ref(), &event).await); + continue; + } + SignerJob::SealBackup { backup, created_at, reply } => { + let _ = reply.send(crate::backup::seal_backup(signer.as_ref(), &backup, created_at).await); + continue; + } + SignerJob::SealDeletion { created_at, reply } => { + let _ = reply.send(crate::backup::seal_deletion(signer.as_ref(), created_at).await); + continue; + } }; if tx.send(msg).is_err() { return; @@ -849,6 +891,12 @@ struct Loop { pair_timer: Option, /// The delete-controller's 4 s undo window. undo_timer: Option, + /// The pending config backup save (see `runtime::backup`). + backup_timer: Option, + /// A backup look, save or deletion is under way. + backup_busy: bool, + /// The `created_at` (s) of the last backup event made. + backup_created_at: u64, /// The machines store changed since it was last written. machines_dirty: bool, /// A stored-event cursor not written yet. @@ -1009,6 +1057,10 @@ impl Loop { } Msg::CommandSigned { machine, event, reply, outbox_id } => self.publish_built(&machine, event, reply, outbox_id), Msg::DirectEvent(event) => self.nostr.deliver(&event), + Msg::BackupDue => self.on_backup_due(), + Msg::BackupFetched(found) => self.on_backup_fetched(found), + Msg::BackupSaved(saved) => self.on_backup_saved(saved).await, + Msg::BackupDeleted => self.forget_backup_relay().await, Msg::DirectState { machine, endpoint } => { let changed = match endpoint { Some(endpoint) => self.links_up.insert(machine, endpoint.clone()) != Some(endpoint), @@ -1196,7 +1248,7 @@ impl Loop { self.persist_store(StoreId::Machines).await; if self.session.drop_unused_previous(&self.stores.machines, now) { log::info!("session key: every bridge moved to the new key; the previous one is gone"); - self.key_store.save(&self.session.encode()).await; + self.session_ring_changed().await; } } self.interpret_route(result).await; @@ -1269,10 +1321,20 @@ impl Loop { } log::info!("session key: replaced by a fresh one"); self.session.drop_unused_previous(&self.stores.machines, now); + self.session_ring_changed().await; + } + + /// The session-key ring changed (rotated, pruned, or taken from a + /// backup): store it, grant and encrypt under its current key, and back + /// it up. + pub(super) async fn session_ring_changed(&mut self) { self.key_store.save(&self.session.encode()).await; - self.keys = PhoneKeys::new(&self.keys.identity_pubkey_hex, &self.session.current); - // The old key's grant attempts say nothing about the new one. - self.grant_attempts.clear(); + if self.keys.session_pubkey_hex != self.session.current.pubkey_hex() { + self.keys = PhoneKeys::new(&self.keys.identity_pubkey_hex, &self.session.current); + // The old key's grant attempts say nothing about the new one. + self.grant_attempts.clear(); + } + self.backup_changed(); } fn emit(&self, event: CoreEvent) { @@ -1412,6 +1474,10 @@ impl Loop { } async fn persist_store(&mut self, id: StoreId) { + // What these hold goes in the config backup. + if id != StoreId::Outbox { + self.backup_changed(); + } let p = Persister::new(self.kv.as_ref()); match id { StoreId::Machines => { @@ -1483,6 +1549,7 @@ impl Loop { reply: oneshot::Sender<()>, ) -> Option> { let mut reply = Some(reply); + let Some(intent) = self.on_backup_intent(intent).await else { return reply }; // A pairing grants the current key: never one about to be replaced. self.rotate_session_key_if_due().await; let ctx = IntentCtx { @@ -2113,5 +2180,6 @@ fn egress_detail(err: &EgressError) -> String { } } +mod backup; #[cfg(test)] mod tests; diff --git a/crates/client-runtime/src/runtime/backup.rs b/crates/client-runtime/src/runtime/backup.rs new file mode 100644 index 00000000..0d4672ee --- /dev/null +++ b/crates/client-runtime/src/runtime/backup.rs @@ -0,0 +1,313 @@ +//! The loop's side of the config backup: when to look for one, when to save +//! one, importing and turning it off. The events themselves are +//! `crate::backup`'s; the payload is `client_core::stores::backup`'s. +//! +//! A backup is saved a while after the last change worth backing up (a +//! paired machine, the settings, the quick prompts), and only when what it +//! holds changed. Nothing is saved while a backup found on the relay waits +//! for the user to import it or keep this phone's, so a new phone never +//! overwrites the backup it is about to restore. + +use std::cell::RefCell; +use std::rc::Rc; +use std::time::Duration; + +use client_core::stores::backup::{backup_d_tag, backup_fingerprint, build_backup, merge_backup, ConfigBackup, BACKUP_KIND}; +use client_core::stores::pairing::is_relay_url; +use nostr_transport::{Filter, SubCallbacks, Transport}; +use tokio::sync::oneshot; + +use super::{abort, Loop, Msg, SignerJob}; +use crate::backup::{newest_backup, BackupStatus}; +use crate::dispatch::StoreId; +use crate::intent::Intent; +use crate::nostr_client::NostrEvent; +use crate::stores::Persister; +use crate::transport::ws::{WsTransport, PUBLISH_CONFIRM_ATTEMPTS, PUBLISH_CONFIRM_BUDGET}; +use super::SliceId; + +/// How long after the last change a backup is saved: a burst of edits (or +/// the heartbeats a pairing brings) costs one event. +const SAVE_AFTER: Duration = Duration::from_secs(30); +/// How long to wait for the backup relay to come up before looking on it. +const RELAY_WAIT: Duration = Duration::from_secs(20); +/// How long a look on the relay may take once it is up. +const FETCH_BUDGET: Duration = Duration::from_secs(15); + +/// What a look on the relay found. +pub(super) type Fetched = Result, String>; + +impl Loop { + /// Take a backup intent; any other comes back for the stores. + pub(super) async fn on_backup_intent(&mut self, intent: Intent) -> Option { + match intent { + Intent::SetBackupRelay(url) => self.set_backup_relay(url.trim()).await, + Intent::ImportBackup => self.import_backup().await, + Intent::KeepLocalConfig => { + if self.stores.backup.found.take().is_some() { + self.save_backup(true); + } + } + Intent::BackupNow => self.save_backup(true), + Intent::DisableBackup { delete } => self.disable_backup(delete).await, + other => return Some(other), + } + self.state_changed(SliceId::Settings); + None + } + + async fn set_backup_relay(&mut self, relay: &str) { + if !is_relay_url(relay) { + self.stores.backup.status = + BackupStatus::Failed { reason: "Enter a relay address that starts with wss://.".into() }; + return; + } + abort(&mut self.backup_timer); + let backup = &mut self.stores.backup; + backup.config.relay = Some(relay.to_string()); + backup.config.saved_at = None; + backup.config.fingerprint = None; + backup.found = None; + Persister::new(self.kv.as_ref()).save_backup(&self.stores.backup).await; + self.sync_relays(); + self.fetch_backup(); + } + + /// Look on the backup relay for this identity's backup. + fn fetch_backup(&mut self) { + let Some(relay) = self.stores.backup.config.relay.clone() else { return }; + self.backup_busy = true; + self.stores.backup.status = BackupStatus::Checking; + let ws = self.ws.clone(); + let me = self.signer.pubkey_hex(); + let jobs = self.signer_jobs.clone(); + let tx = self.self_tx.clone(); + tokio::task::spawn_local(async move { + let found = match fetch_event(&ws, &relay, &me).await { + Ok(None) => Ok(None), + Ok(Some(event)) => { + let (reply, answer) = oneshot::channel(); + let _ = jobs.send(SignerJob::OpenBackup { event, reply }); + answer.await.unwrap_or_else(|_| Err("The backup could not be read.".into())).map(Some) + } + Err(reason) => Err(reason), + }; + let _ = tx.send(Msg::BackupFetched(found)); + }); + } + + pub(super) fn on_backup_fetched(&mut self, found: Fetched) { + self.backup_busy = false; + match found { + Ok(Some(backup)) => { + self.stores.backup.status = BackupStatus::Found { + saved_at: backup.saved_at, + machines: backup.machines.len() as u32, + }; + self.stores.backup.found = Some(backup); + } + // Nothing there yet: this phone's is the first. + Ok(None) => { + self.stores.backup.status = BackupStatus::Idle; + self.save_backup(true); + } + Err(reason) => self.stores.backup.status = BackupStatus::Failed { reason }, + } + self.state_changed(SliceId::Settings); + } + + async fn import_backup(&mut self) { + let Some(backup) = self.stores.backup.found.take() else { return }; + self.stores.backup.status = BackupStatus::Importing; + let tor_before = self.stores.settings.data.tor_proxy_enabled; + let now = self.clock.now_ms(); + let stores = &mut self.stores; + let summary = + merge_backup(&backup, &mut stores.machines, &mut stores.settings, &mut stores.quick_prompts, &mut self.session, now); + log::info!( + "backup: imported ({} machines added, {} kept, session keys {})", + summary.added, + summary.kept, + if summary.adopted_keys { "taken" } else { "kept" } + ); + if summary.adopted_keys { + self.session_ring_changed().await; + } + for id in [StoreId::Machines, StoreId::Settings, StoreId::QuickPrompts] { + self.persist_store(id).await; + self.state_changed(super::slice_of(id)); + } + self.flush_writes().await; + let tor = self.stores.settings.data.tor_proxy_enabled; + if tor != tor_before { + let proxy = if tor { self.tor_proxy_address.clone() } else { None }; + self.nostr.set_proxy(proxy.clone()); + self.http.set_proxy(proxy.as_deref()); + } + self.sync_relays(); + self.refresh_authors(); + self.sync_direct_links(); + self.stores.backup.status = BackupStatus::Idle; + // What the phone has now: the backup, plus anything it kept. + self.save_backup(false); + } + + async fn disable_backup(&mut self, delete: bool) { + abort(&mut self.backup_timer); + self.stores.backup.found = None; + match self.stores.backup.config.relay.clone() { + Some(relay) if delete => { + self.backup_busy = true; + self.stores.backup.status = BackupStatus::Saving; + let ws = self.ws.clone(); + let jobs = self.signer_jobs.clone(); + let tx = self.self_tx.clone(); + let created_at = self.next_backup_created_at(); + tokio::task::spawn_local(async move { + let (reply, answer) = oneshot::channel(); + let _ = jobs.send(SignerJob::SealDeletion { created_at, reply }); + if let Ok(Ok(event)) = answer.await { + let result = ws.publish_confirmed_to(&event, &[relay], PUBLISH_CONFIRM_BUDGET, PUBLISH_CONFIRM_ATTEMPTS).await; + if !result.verdict.is_delivered() { + log::warn!("backup: deletion not delivered: {:?} {:?}", result.verdict, result.detail); + } + } + let _ = tx.send(Msg::BackupDeleted); + }); + } + _ => self.forget_backup_relay().await, + } + } + + pub(super) async fn forget_backup_relay(&mut self) { + self.backup_busy = false; + let backup = &mut self.stores.backup; + backup.config = Default::default(); + backup.status = BackupStatus::Idle; + Persister::new(self.kv.as_ref()).save_backup(&self.stores.backup).await; + self.sync_relays(); + self.state_changed(SliceId::Settings); + } + + /// Something worth backing up changed: save in a while. + pub(super) fn backup_changed(&mut self) { + if self.stores.backup.config.relay.is_some() && self.backup_timer.is_none() { + self.backup_timer = Some(self.arm(SAVE_AFTER.as_millis() as u64, Msg::BackupDue)); + } + } + + pub(super) fn on_backup_due(&mut self) { + self.backup_timer = None; + self.save_backup(false); + self.state_changed(SliceId::Settings); + } + + /// Save the backup now, unless `force` is off and it holds what was last + /// saved. Never while a found backup waits on the user, or another + /// operation runs (a change meanwhile arms the next save). + fn save_backup(&mut self, force: bool) { + let Some(relay) = self.stores.backup.config.relay.clone() else { return }; + if self.backup_busy || self.stores.backup.found.is_some() { + return; + } + let now = self.clock.now_ms(); + let backup = + build_backup(&self.stores.machines, &self.stores.settings.data, &self.stores.quick_prompts.prompts, &self.session, now); + let fingerprint = backup_fingerprint(&backup); + if !force && self.stores.backup.config.fingerprint.as_deref() == Some(fingerprint.as_str()) { + return; + } + self.backup_busy = true; + self.stores.backup.status = BackupStatus::Saving; + let created_at = self.next_backup_created_at(); + let ws = self.ws.clone(); + let jobs = self.signer_jobs.clone(); + let tx = self.self_tx.clone(); + tokio::task::spawn_local(async move { + let (reply, answer) = oneshot::channel(); + let _ = jobs.send(SignerJob::SealBackup { backup: Box::new(backup), created_at, reply }); + let saved = match answer.await.unwrap_or_else(|_| Err("The backup could not be sealed.".into())) { + Ok(event) => { + let result = ws.publish_confirmed_to(&event, &[relay], PUBLISH_CONFIRM_BUDGET, PUBLISH_CONFIRM_ATTEMPTS).await; + if result.verdict.is_delivered() { + Ok((now, fingerprint)) + } else { + Err(match result.detail { + Some(detail) => format!("The relay did not take the backup: {detail}"), + None => "The backup relay could not be reached.".into(), + }) + } + } + Err(reason) => Err(reason), + }; + let _ = tx.send(Msg::BackupSaved(saved)); + }); + } + + pub(super) async fn on_backup_saved(&mut self, saved: Result<(u64, String), String>) { + self.backup_busy = false; + match saved { + Ok((at, fingerprint)) => { + self.stores.backup.config.saved_at = Some(at); + self.stores.backup.config.fingerprint = Some(fingerprint); + self.stores.backup.status = BackupStatus::Idle; + Persister::new(self.kv.as_ref()).save_backup(&self.stores.backup).await; + } + Err(reason) => { + log::warn!("backup: {reason}"); + self.stores.backup.status = BackupStatus::Failed { reason }; + } + } + self.state_changed(SliceId::Settings); + } + + /// A relay keeps the newest backup and, between two of the same second, + /// the one with the lower id: each one is dated past the last. + fn next_backup_created_at(&mut self) -> u64 { + let at = (self.clock.now_ms() / 1000).max(self.backup_created_at + 1); + self.backup_created_at = at; + at + } +} + +/// The identity's newest backup on `relay`, once the relay is up. +async fn fetch_event(ws: &WsTransport, relay: &str, me: &str) -> Result, String> { + let deadline = tokio::time::Instant::now() + RELAY_WAIT; + while !ws.connected_relays().contains(relay) { + if tokio::time::Instant::now() >= deadline { + return Err("The backup relay could not be reached.".into()); + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + let events: Rc>> = Rc::default(); + let (done, ended) = oneshot::channel::<()>(); + let done = Rc::new(RefCell::new(Some(done))); + let collected = Rc::clone(&events); + let (eose, closed) = (Rc::clone(&done), Rc::clone(&done)); + let sub = ws.subscribe( + Filter { + kinds: vec![BACKUP_KIND], + authors: vec![me.to_string()], + p_tags: vec![], + d_tags: vec![backup_d_tag(me)], + since: None, + }, + SubCallbacks { + on_event: Rc::new(move |e: &NostrEvent| collected.borrow_mut().push(e.clone())), + on_eose: Rc::new(move || { + if let Some(done) = eose.borrow_mut().take() { + let _ = done.send(()); + } + }), + on_close: Rc::new(move |_| { + closed.borrow_mut().take(); + }), + }, + ); + let answered = tokio::time::timeout(FETCH_BUDGET, ended).await; + sub.close(); + match answered { + Ok(Ok(())) => Ok(newest_backup(&events.borrow(), me).cloned()), + _ => Err("The backup relay did not answer.".into()), + } +} diff --git a/crates/client-runtime/src/runtime/tests.rs b/crates/client-runtime/src/runtime/tests.rs index 6ec6b880..02368479 100644 --- a/crates/client-runtime/src/runtime/tests.rs +++ b/crates/client-runtime/src/runtime/tests.rs @@ -2236,3 +2236,110 @@ fn core_event_json_shape_is_externally_tagged_camel_case() { serde_json::json!({ "actionFailed": { "kind": "publishRejected" } }), ); } + +/// The next frame of `kind` (`REQ`, `EVENT`, …), skipping the others. +async fn next_frame_of(mock: &mut MockRelay, kind: &str) -> Vec { + for _ in 0..20 { + let frame = mock.next_frame().await; + let v: Vec = serde_json::from_str(&frame).unwrap(); + if v[0] == kind { + return v; + } + } + panic!("no {kind} frame"); +} + +/// The backup look's REQ: answered with `events` then EOSE. +async fn answer_backup_req(mock: &mut MockRelay, me: &str, events: &[serde_json::Value]) { + loop { + let req = next_frame_of(mock, "REQ").await; + let sub = req[1].as_str().unwrap().to_string(); + if req[2]["#d"].is_array() { + assert_eq!(req[2]["kinds"], serde_json::json!([30078])); + assert_eq!(req[2]["authors"], serde_json::json!([me])); + for e in events { + mock.push(serde_json::json!(["EVENT", sub, e]).to_string()); + } + mock.push(format!(r#"["EOSE","{sub}"]"#)); + return; + } + mock.push(format!(r#"["EOSE","{sub}"]"#)); + } +} + +async fn settings_backup(core: &Core) -> crate::view::BackupView { + core.settings_view().await.unwrap().backup +} + +#[tokio::test] +async fn a_backup_saved_by_one_phone_restores_the_machines_on_another() { + LocalSet::new() + .run_until(async { + let phone = keypair_from_secret_hex(SEC_PHONE).unwrap(); + let machine = generate_keypair(); + + // The old phone, paired with a machine, turns the backup on. + let mut first = mock_relay().await; + let mut state = client_core::stores::machines::MachinesState::default(); + // Names with spaces: base64 ciphertext can never contain them by chance. + state.register_machine(&machine.pubkey_hex, "my laptop", Some("Work lab".into()), None, &[first.url.clone()]); + let kv = MemoryKv::seeded([( + crate::stores::MACHINES_KEY, + client_core::stores::machines::serialize_machines(&state.machines), + )]); + let old_keys = MemoryKeyStore::default(); + let ports = CorePorts { kv: Rc::new(kv), session_keys: Some(Rc::new(old_keys.clone())), ..CorePorts::default() }; + let old = core_for_ports(&first, &phone, Rc::new(Spy::default()), ports).await; + old.start(); + old.dispatch(Intent::SetBackupRelay(first.url.clone())).await; + // Nothing on the relay yet: this phone's is saved. + answer_backup_req(&mut first, &phone.pubkey_hex, &[]).await; + let event = next_frame_of(&mut first, "EVENT").await[1].clone(); + assert_eq!(event["kind"], 30078); + let wire = event.to_string(); + let old_secret = old_keys.ring().current.keypair.secret_hex(); + assert!(!wire.contains("my laptop") && !wire.contains("Work lab") && !wire.contains(&machine.pubkey_hex)); + assert!(!wire.contains(&old_secret)); + first.push(serde_json::json!(["OK", event["id"], true, ""]).to_string()); + settle().await; + let saved = settings_backup(&old).await; + assert_eq!(saved.relay.as_deref(), Some(first.url.as_str())); + assert!(saved.saved_at.is_some()); + + // A fresh phone with the same identity finds it and imports it. + let mut second = mock_relay().await; + let new_keys = MemoryKeyStore::default(); + let ports = CorePorts { session_keys: Some(Rc::new(new_keys.clone())), ..CorePorts::default() }; + let new = core_for_ports(&second, &phone, Rc::new(Spy::default()), ports).await; + new.start(); + assert_ne!(new_keys.ring().current.keypair.secret_hex(), old_secret); + new.dispatch(Intent::SetBackupRelay(second.url.clone())).await; + answer_backup_req(&mut second, &phone.pubkey_hex, &[event]).await; + settle().await; + assert!(matches!(settings_backup(&new).await.status, crate::backup::BackupStatus::Found { machines: 1, .. })); + assert!(new.machines_view().await.machines.is_empty(), "nothing is imported before the user says so"); + + new.dispatch(Intent::ImportBackup).await; + let machines = new.machines_view().await.machines; + let restored = &machines[&machine.pubkey_hex]; + assert_eq!((restored.label.as_deref(), restored.relays.clone()), (Some("Work lab"), vec![first.url.clone()])); + // It granted nothing of its own yet, so it takes the old phone's session keys. + assert_eq!(new_keys.ring().current.keypair.secret_hex(), old_secret); + }) + .await; +} + +#[tokio::test] +async fn a_relay_address_the_phone_may_not_dial_is_refused() { + LocalSet::new() + .run_until(async { + let mock = mock_relay().await; + let phone = keypair_from_secret_hex(SEC_PHONE).unwrap(); + let core = core_for(&mock, &phone, Rc::new(Spy::default())).await; + core.dispatch(Intent::SetBackupRelay("ws://plain.example".into())).await; + let backup = settings_backup(&core).await; + assert_eq!(backup.relay, None); + assert!(matches!(backup.status, crate::backup::BackupStatus::Failed { .. })); + }) + .await; +} diff --git a/crates/client-runtime/src/stores.rs b/crates/client-runtime/src/stores.rs index 18a8b87c..55fd6874 100644 --- a/crates/client-runtime/src/stores.rs +++ b/crates/client-runtime/src/stores.rs @@ -18,7 +18,10 @@ use client_core::stores::pending_sessions::PendingSessionsState; use client_core::stores::quick_prompts::{ hydrate_quick_prompts, serialize_quick_prompts, QuickPromptsState, QUICK_PROMPTS_STORAGE_KEY, }; +use client_core::stores::backup::{hydrate_backup_config, serialize_backup_config, BackupConfig, ConfigBackup, BACKUP_STORAGE_KEY}; use client_core::stores::settings::{hydrate_settings, serialize_settings, SettingsState}; + +use crate::backup::BackupStatus; use client_core::stores::transcript::TranscriptState; use client_core::stores::ui::UiState; @@ -31,6 +34,8 @@ pub const DISMISSED_SESSIONS_KEY: &str = "machines.dismissed"; pub const OUTBOX_KEY: &str = "outbox"; pub const SETTINGS_KEY: &str = "settings"; pub const QUICK_PROMPTS_KEY: &str = QUICK_PROMPTS_STORAGE_KEY; +/// The config backup's relay and what was last saved there. +pub const BACKUP_KEY: &str = BACKUP_STORAGE_KEY; /// The session keys, when the host keeps them in the KV (see /// [`crate::ports::KvSessionKeyStore`]). pub const SESSION_KEYS_KEY: &str = "session.keys"; @@ -41,6 +46,16 @@ pub const OLD_SESSION_KEY_KEY: &str = "session.secretKey"; /// resumes its since-window. pub const LAST_STORED_SEEN_KEY: &str = "client.lastStoredSeen"; +/// The config backup: its relay (persisted), where the current operation +/// stands, and a backup found on the relay the user has not decided on yet +/// (in memory only, never written down). +#[derive(Debug, Clone, Default, PartialEq)] +pub struct BackupState { + pub config: BackupConfig, + pub status: BackupStatus, + pub found: Option, +} + /// Boot options for the store bundle. #[derive(Debug, Clone, Default)] pub struct StoresConfig { @@ -58,6 +73,7 @@ pub struct CoreStores { pub pairing: PairingState, pub settings: SettingsState, pub quick_prompts: QuickPromptsState, + pub backup: BackupState, pub ui: UiState, pub notifications: NotificationCoordinator, pub delete_controller: DeleteController, @@ -65,10 +81,13 @@ pub struct CoreStores { impl CoreStores { /// The relays the transport dials: every paired machine's, plus a pairing - /// candidate's, which its pair-request and pair-ack travel over. + /// candidate's, which its pair-request and pair-ack travel over, plus the + /// config backup's. Dialled like the others, so through Tor when it is + /// on, and answering the relay's AUTH with the identity. pub fn relay_set(&self) -> Vec { let mut relays = self.machines.relay_set(); - for relay in self.pairing.candidate.iter().flat_map(|c| &c.relays) { + let candidate = self.pairing.candidate.iter().flat_map(|c| &c.relays); + for relay in candidate.chain(self.backup.config.relay.as_ref()) { if !relays.contains(relay) { relays.push(relay.clone()); } @@ -157,6 +176,10 @@ pub async fn hydrate( config: &StoresConfig, ) -> HydratedCore { let settings = SettingsState::new(hydrate_settings(kv.get(SETTINGS_KEY).await.as_deref())); + let backup = BackupState { + config: hydrate_backup_config(kv.get(BACKUP_KEY).await.as_deref()), + ..BackupState::default() + }; let quick_prompts = QuickPromptsState::from_hydrated(hydrate_quick_prompts(kv.get(QUICK_PROMPTS_KEY).await.as_deref())); let mut machines = MachinesState::new( @@ -192,6 +215,7 @@ pub async fn hydrate( pairing: PairingState::default(), settings, quick_prompts, + backup, ui: UiState::default(), notifications: NotificationCoordinator::default(), delete_controller: DeleteController::default(), @@ -233,6 +257,10 @@ impl<'a> Persister<'a> { .await; } + pub async fn save_backup(&self, s: &BackupState) { + self.kv.set(BACKUP_KEY, &serialize_backup_config(&s.config)).await; + } + pub async fn save_last_stored_seen(&self, ts: i64) { self.kv.set(LAST_STORED_SEEN_KEY, &ts.to_string()).await; } diff --git a/crates/client-runtime/src/view.rs b/crates/client-runtime/src/view.rs index fc97883d..f60b2487 100644 --- a/crates/client-runtime/src/view.rs +++ b/crates/client-runtime/src/view.rs @@ -110,12 +110,35 @@ impl OutboxView { // --- settings ------------------------------------------------------------ #[derive(Debug, Clone, PartialEq, Serialize, specta::Type)] -#[serde(transparent)] -pub struct SettingsView(pub SettingsData); +#[serde(rename_all = "camelCase")] +pub struct SettingsView { + #[serde(flatten)] + pub data: SettingsData, + pub backup: BackupView, +} + +/// The config backup as the settings page shows it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct BackupView { + /// `None`: backup is off. + pub relay: Option, + /// When a backup last reached the relay (ms). + #[specta(type = Option)] + pub saved_at: Option, + pub status: crate::backup::BackupStatus, +} impl SettingsView { pub fn from_stores(s: &CoreStores) -> Self { - Self(s.settings.data.clone()) + Self { + data: s.settings.data.clone(), + backup: BackupView { + relay: s.backup.config.relay.clone(), + saved_at: s.backup.config.saved_at, + status: s.backup.status.clone(), + }, + } } } @@ -405,9 +428,10 @@ mod tests { let mv = MachinesView::from_stores(&s); assert_eq!(mv.machines["m"].relays, vec!["wss://extra.example"]); let sv = SettingsView::from_stores(&s); - assert_eq!(sv.0.ui_scale, 1.2); - // transparent — serializes as the bare SettingsData - assert!(serde_json::to_string(&sv).unwrap().starts_with('{')); + assert_eq!(sv.data.ui_scale, 1.2); + assert_eq!(sv.backup.relay, None); + // The settings' own fields stay at the top level. + assert!(serde_json::to_string(&sv).unwrap().contains("\"uiScale\":1.2")); } #[tokio::test] diff --git a/crates/nostr-transport/src/frames.rs b/crates/nostr-transport/src/frames.rs index 2d9e539a..068efd00 100644 --- a/crates/nostr-transport/src/frames.rs +++ b/crates/nostr-transport/src/frames.rs @@ -106,6 +106,9 @@ pub fn filter_to_json(filter: &Filter) -> Value { if !filter.p_tags.is_empty() { map.insert("#p".to_string(), json!(filter.p_tags)); } + if !filter.d_tags.is_empty() { + map.insert("#d".to_string(), json!(filter.d_tags)); + } if let Some(since) = filter.since { map.insert("since".to_string(), json!(since)); } @@ -213,24 +216,26 @@ mod tests { kinds: vec![4516], authors: vec!["a1".into()], p_tags: vec!["p1".into()], + d_tags: vec!["d1".into()], since: Some(1000), }; assert_eq!( filter_to_json(&full), - json!({ "kinds": [4516], "authors": ["a1"], "#p": ["p1"], "since": 1000 }) + json!({ "kinds": [4516], "authors": ["a1"], "#p": ["p1"], "#d": ["d1"], "since": 1000 }) ); let no_since = Filter { kinds: vec![30515], authors: vec!["a1".into()], p_tags: vec!["p1".into()], + d_tags: vec![], since: None, }; let v = filter_to_json(&no_since); assert!(v.get("since").is_none()); assert_eq!(v["kinds"], json!([30515])); - let empty = Filter { kinds: vec![], authors: vec![], p_tags: vec![], since: None }; + let empty = Filter { kinds: vec![], authors: vec![], p_tags: vec![], d_tags: vec![], since: None }; assert_eq!(filter_to_json(&empty), json!({})); } diff --git a/crates/nostr-transport/src/port.rs b/crates/nostr-transport/src/port.rs index 02198d5d..9803caad 100644 --- a/crates/nostr-transport/src/port.rs +++ b/crates/nostr-transport/src/port.rs @@ -43,6 +43,8 @@ pub struct Filter { pub authors: Vec, /// the `#p` tag filter. pub p_tags: Vec, + /// the `#d` tag filter (an addressable event's identifier). + pub d_tags: Vec, pub since: Option, } diff --git a/crates/nostr-transport/src/ws.rs b/crates/nostr-transport/src/ws.rs index b6a70d16..0fc7fb38 100644 --- a/crates/nostr-transport/src/ws.rs +++ b/crates/nostr-transport/src/ws.rs @@ -1529,6 +1529,7 @@ mod tests { kinds: vec![24515], authors: vec!["a".repeat(64)], p_tags: vec![phone.pubkey_hex.clone()], + d_tags: vec![], since: None, } } diff --git a/crates/protocol/src/crypto.rs b/crates/protocol/src/crypto.rs index f57ad2ae..9bc0f47c 100644 --- a/crates/protocol/src/crypto.rs +++ b/crates/protocol/src/crypto.rs @@ -72,6 +72,15 @@ pub fn npub_from_hex(pubkey_hex: &str) -> Result { .map_err(|_| CryptoError::InvalidKey) } +/// `nsec…` for a hex secret key, for the user to copy their own key out of +/// an on-device login. A secret: never log it. +pub fn nsec_from_secret_hex(secret_hex: &str) -> Result { + SecretKey::from_hex(secret_hex) + .map_err(|_| CryptoError::InvalidKey)? + .to_bech32() + .map_err(|_| CryptoError::InvalidKey) +} + /// hex pubkey for an `npub…` (pairing URL / manual-pair input). `Err` on a /// non-`npub` bech32 (e.g. an `nsec`) or garbage — never a panic. pub fn hex_from_npub(npub: &str) -> Result { @@ -155,6 +164,16 @@ mod tests { ); } + #[test] + fn nsec_encodes_a_secret_and_rejects_garbage() { + // NIP-19's own example. + assert_eq!( + nsec_from_secret_hex("67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa").unwrap(), + "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5" + ); + assert_eq!(nsec_from_secret_hex("nothex"), Err(CryptoError::InvalidKey)); + } + #[test] fn hex_helpers_match_ts_strictness() { assert_eq!(hex_to_bytes("deadbeef").unwrap(), vec![0xde, 0xad, 0xbe, 0xef]); diff --git a/docs/CLIENT.md b/docs/CLIENT.md index 6c6cd38e..124e3efe 100644 --- a/docs/CLIENT.md +++ b/docs/CLIENT.md @@ -108,6 +108,32 @@ cost one ack per window rather than one each); one NIP-42 `AUTH` per relay connection that challenges; and a grant, rarely. Everything else it signs — commands, Blossom upload auth — follows a user action. +**Config backup.** Opt-in, in Settings → Backup, and offered once right +after logging in with an existing key. The phone saves what only it knows — +each paired machine's relays, label, direct endpoints and new-session +defaults, the settings, the quick prompts, and its session keys with their +expiries and each machine's confirmed grant — as one NIP-78 event (kind +30078) on a relay the user picks. The content is NIP-44 encrypted to the +identity itself, through the signer, so a NIP-55 login works too; the `d` +tag is a hash of the identity and an app-private context, so it names +neither the app nor what it holds. What a bridge sends again in its +heartbeat (sessions, agents, models, credentials) is left out. + +A save follows 30 s after the last change worth backing up, and only when +the content changed; "Back up now" forces one. Turning it on looks on the +relay first: a backup already there waits for the user to restore it or +replace it, and nothing is saved over it meanwhile. Restoring merges: the +machines the phone lacks are added, the ones it has keep their own setup, +and the settings and quick prompts become the backup's. The phone takes the +backup's session keys only while it has granted none of its own, so a +restored phone reads its bridges' messages at once without asking the +signer to grant every bridge again; a session key never signs, so it can +read payloads but never command a bridge, and only the identity can open +the backup. Turning backup off can also send a NIP-09 deletion. The backup +relay is used for nothing else, and the Tor and AUTH rules of every relay +apply to it. A login kept on the phone can show and copy its `nsec` +(Settings → Account), the one thing a backup cannot hold. + **Orbot.** A settings toggle routes the relay connections *and* Blossom traffic through Orbot's SOCKS5 proxy (`127.0.0.1:9050`); DNS resolves at the proxy, so `.onion` relays and Blossom servers work. The app does not launch