From 4178e5e5446d116b11ced0abc38f7c5144cf2474 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Wed, 15 Jul 2026 00:37:35 +0200 Subject: [PATCH] Rotate keys --- .../database/dao/MarmotKeyPackageBundleDao.kt | 7 ++ .../repository/DatabaseMarmotRepository.kt | 18 ++- .../compose/repository/MarmotRepository.kt | 18 +++ .../composable/KeyPackageManagementScreen.kt | 104 +++++++++++++----- .../model/KeyPackageManagementViewModel.kt | 19 +++- .../compose/ui/view/model/NotaryViewModel.kt | 94 ++++++++++------ 6 files changed, 194 insertions(+), 66 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/MarmotKeyPackageBundleDao.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/MarmotKeyPackageBundleDao.kt index 8102afdd..313b94d2 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/MarmotKeyPackageBundleDao.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/MarmotKeyPackageBundleDao.kt @@ -5,9 +5,16 @@ import androidx.room3.Query import androidx.room3.Upsert import at.torch.compose.database.model.MarmotKeyPackageBundle import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.flow.Flow @Dao interface MarmotKeyPackageBundleDao { + @Query("SELECT * FROM MarmotKeyPackageBundle WHERE publicKey = :publicKey AND consumed = false AND rotated = false") + fun observeActiveMarmotKeyPackageBundle(publicKey: String): Flow + + @Query("SELECT * FROM MarmotKeyPackageBundle WHERE publicKey = :publicKey ORDER BY createdAt DESC") + fun observeAllMarmotKeyPackageBundles(publicKey: HexKey): Flow> + @Query("SELECT * FROM MarmotKeyPackageBundle WHERE publicKey = :publicKey ORDER BY createdAt DESC") fun getAllMarmotKeyPackageBundles(publicKey: HexKey): List diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/database/repository/DatabaseMarmotRepository.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/database/repository/DatabaseMarmotRepository.kt index 4e6ae3ae..21bc73eb 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/database/repository/DatabaseMarmotRepository.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/database/repository/DatabaseMarmotRepository.kt @@ -5,7 +5,6 @@ import at.torch.compose.database.model.MarmotInnerEvent import at.torch.compose.database.model.MarmotKeyPackageBundle import at.torch.compose.database.model.UnsignedNostrEvent import at.torch.compose.extensions.toHex -import at.torch.compose.managers.MarmotInboundManager import at.torch.compose.nostr.Relays import at.torch.compose.repository.MarmotRepository import co.touchlab.kermit.Logger @@ -26,7 +25,6 @@ import com.vitorpamplona.quartz.marmot.mls.tree.Lifetime import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher -import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope @@ -95,6 +93,18 @@ class DatabaseMarmotRepository( ) } + override suspend fun rotateMarmotKeyPackageBundle(marmotKeyPackageBundle: MarmotKeyPackageBundle) { + database.marmotKeyPackageBundleDao().upsert( + marmotKeyPackageBundle.copy( + rotated = true + ) + ) + } + + override suspend fun observeMarmotKeyPackageBundles(publicKey: HexKey): Flow> { + return database.marmotKeyPackageBundleDao().observeAllMarmotKeyPackageBundles(publicKey) + } + override suspend fun getMarmotKeyPackageBundles(publicKey: HexKey): List { return database.marmotKeyPackageBundleDao().getAllMarmotKeyPackageBundles(publicKey) } @@ -103,6 +113,10 @@ class DatabaseMarmotRepository( return database.marmotInnerEventDao().observeUnprocessedMarmotInnerEvents(publicKey) } + override suspend fun observeActiveMarmotKeyPackageBundle(publicKey: HexKey): Flow { + return database.marmotKeyPackageBundleDao().observeActiveMarmotKeyPackageBundle(publicKey) + } + override suspend fun encryptAndSendMarmotInnerEvent( marmotInnerEvent: MarmotInnerEvent, nostrSignerSync: NostrSignerSync diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/repository/MarmotRepository.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/repository/MarmotRepository.kt index ba8aa694..8cd62ff2 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/repository/MarmotRepository.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/repository/MarmotRepository.kt @@ -13,10 +13,16 @@ interface MarmotRepository { nsecPassword: String ) + suspend fun rotateMarmotKeyPackageBundle(marmotKeyPackageBundle: MarmotKeyPackageBundle) + + suspend fun observeMarmotKeyPackageBundles(publicKey: HexKey): Flow> + suspend fun getMarmotKeyPackageBundles(publicKey: HexKey): List suspend fun observeUnprocessedMarmotInnerEvents(publicKey: HexKey): Flow + suspend fun observeActiveMarmotKeyPackageBundle(publicKey: HexKey): Flow + suspend fun encryptAndSendMarmotInnerEvent(marmotInnerEvent: MarmotInnerEvent, nostrSignerSync: NostrSignerSync) companion object { @@ -28,6 +34,14 @@ interface MarmotRepository { TODO("Not yet implemented") } + override suspend fun rotateMarmotKeyPackageBundle(marmotKeyPackageBundle: MarmotKeyPackageBundle) { + TODO("Not yet implemented") + } + + override suspend fun observeMarmotKeyPackageBundles(publicKey: HexKey): Flow> { + TODO("Not yet implemented") + } + override suspend fun getMarmotKeyPackageBundles(publicKey: HexKey): List { return emptyList() } @@ -36,6 +50,10 @@ interface MarmotRepository { TODO("Not yet implemented") } + override suspend fun observeActiveMarmotKeyPackageBundle(publicKey: HexKey): Flow { + TODO("Not yet implemented") + } + override suspend fun encryptAndSendMarmotInnerEvent( marmotInnerEvent: MarmotInnerEvent, nostrSignerSync: NostrSignerSync diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/KeyPackageManagementScreen.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/KeyPackageManagementScreen.kt index 776f1649..cc71db54 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/KeyPackageManagementScreen.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/KeyPackageManagementScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Bento +import androidx.compose.material.icons.filled.DoneAll import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Hub import androidx.compose.material.icons.filled.ImportExport @@ -23,11 +24,16 @@ import androidx.compose.material.icons.filled.Logout import androidx.compose.material.icons.filled.PrivateConnectivity import androidx.compose.material.icons.filled.Publish import androidx.compose.material.icons.filled.QrCode +import androidx.compose.material.icons.filled.Unpublished +import androidx.compose.material.icons.filled.Verified +import androidx.compose.material.icons.filled.VerifiedUser import androidx.compose.material3.Button +import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface @@ -38,9 +44,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import at.torch.compose.database.model.MarmotKeyPackageBundle import at.torch.compose.extensions.hexToNpubHrp import at.torch.compose.extensions.toFormattedTimeAndDateString import at.torch.compose.repository.MarmotRepository @@ -54,6 +62,7 @@ import at.torch.compose.ui.view.model.KeyPackageManagementViewModel import at.torch.compose.ui.view.state.KeyPackageManagementUIState import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.phoenix.preferredAmountUnit @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable @@ -147,28 +156,67 @@ fun KeyPackageManagementScreen( items = keyPackageManagementUIState.keyPackageBundles ) { marmotKeyPackageBundle -> - Row { - Text( - text = marmotKeyPackageBundle.id, - style = MaterialTheme.typography.bodySmall - ) - - Text( - text = marmotKeyPackageBundle.createdAt.toFormattedTimeAndDateString(), - style = MaterialTheme.typography.labelSmall - ) - - TextButton( - onClick = { - onNavigateToRoute.invoke( - ImplementationPendingRoute("Delete Key Package") + Card { + ListItem( + leadingContent = { + if (marmotKeyPackageBundle.rotated) { + Icon( + Icons.Default.Unpublished, + contentDescription = "Key package rotated" + ) + } else if (marmotKeyPackageBundle.consumed) { + Icon( + Icons.Default.DoneAll, + contentDescription = "Consumed key package event" + ) + } else { + // TODO: Get broadcast status of key package... + Icon( + Icons.Default.VerifiedUser, + contentDescription = "Key package published" + ) + } + }, + headlineContent = { + Text( + text = marmotKeyPackageBundle.id, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis ) + }, + supportingContent = { + Row( + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = marmotKeyPackageBundle.createdAt.toFormattedTimeAndDateString(), + style = MaterialTheme.typography.labelSmall + ) + + Spacer( + modifier = Modifier.weight(1f) + ) + } + + }, + trailingContent = { + if (marmotKeyPackageBundle.rotated.not() && marmotKeyPackageBundle.consumed.not()) { + IconButton( + onClick = { + keyPackageManagementViewModel.rotateKeyPackage( + marmotKeyPackageBundle + ) + } + ) { + Icon( + Icons.Default.LockReset, + contentDescription = "Rotate Key" + ) + } + } + } - ) { - Text( - "Delete" - ) - } + ) } } } @@ -177,10 +225,6 @@ fun KeyPackageManagementScreen( } KeyPackageManagementUIState.Loading -> { LoadingDataIndicator() - - LaunchedEffect(true) { - keyPackageManagementViewModel.loadNostrFeed() - } } KeyPackageManagementUIState.NotFound -> { Column( @@ -203,7 +247,17 @@ private fun UnannouncedProfileScreenPreview() { ) { KeyPackageManagementScreen( initialActiveProfileUIState = KeyPackageManagementUIState.Loaded( - keyPackageBundles = emptyList() + keyPackageBundles = listOf( + MarmotKeyPackageBundle( + id = "84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240", + publicKey = "", + tlsEncodedMarmotKeyPackage = ByteArray(0), + ncryptsecInitPrivateKey = "", + ncryptsecEncryptionPrivateKey = "", + ncryptsecSignaturePrivateKey = "", + consumed = true + ) + ) ), activeUserPublicKey = "84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240", nostrEventId = "", diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/KeyPackageManagementViewModel.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/KeyPackageManagementViewModel.kt index d19a26e4..9f69061f 100755 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/KeyPackageManagementViewModel.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/KeyPackageManagementViewModel.kt @@ -8,11 +8,13 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory +import at.torch.compose.database.model.MarmotKeyPackageBundle import at.torch.compose.repository.MarmotRepository import at.torch.compose.ui.view.state.KeyPackageManagementUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch class KeyPackageManagementViewModel( @@ -24,16 +26,17 @@ class KeyPackageManagementViewModel( var keyPackageManagementUIState: KeyPackageManagementUIState by mutableStateOf(initialActiveProfileUIState) private set - fun retryLoad() { - keyPackageManagementUIState = KeyPackageManagementUIState.Loading + init { loadNostrFeed() } fun loadNostrFeed() { viewModelScope.launch(Dispatchers.IO) { - keyPackageManagementUIState = KeyPackageManagementUIState.Loaded( - keyPackageBundles = marmotRepository.getMarmotKeyPackageBundles(activeUserPublicKey) - ) + marmotRepository.observeMarmotKeyPackageBundles(activeUserPublicKey).distinctUntilChanged().collect { keyPackageBundles -> + keyPackageManagementUIState = KeyPackageManagementUIState.Loaded( + keyPackageBundles = keyPackageBundles + ) + } } } @@ -50,6 +53,12 @@ class KeyPackageManagementViewModel( } } + fun rotateKeyPackage(marmotKeyPackageBundle: MarmotKeyPackageBundle) { + viewModelScope.launch(Dispatchers.IO) { + marmotRepository.rotateMarmotKeyPackageBundle(marmotKeyPackageBundle) + } + } + companion object { const val TAG = "KeyPackageManagementViewModel" diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/NotaryViewModel.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/NotaryViewModel.kt index 08361750..89a90cca 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/NotaryViewModel.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/NotaryViewModel.kt @@ -68,6 +68,7 @@ class NotaryViewModel( observeUnsignedNostrEvents(keyPair) observeUnsealedGiftWrapPayloads(keyPair) observeUnprocessedMarmotInnerEvents(keyPair) + observeActiveMarmotKeyPackageBundle(keyPair) } } } @@ -77,45 +78,45 @@ class NotaryViewModel( private fun observeUnsignedNostrEvents( keyPair: KeyPair ) { - val tempSigner = NostrSignerSync( - keyPair - ) - scope.launch(Dispatchers.IO) { - logger.i { "observeUnsignedNostrEvents: ${keyPair.pubKey.toHexKey()}" } - nostrRepository.observeUnsignedNostrEvents( - publicKey = keyPair.pubKey.toHexKey() - ).distinctUntilChanged().collect { unsignedNostrEventOrNull -> - scope.launch(Dispatchers.IO) { - unsignedNostrEventOrNull?.let { unsignedNostrEvent -> - logger.d("Unsigned: ${unsignedNostrEvent.kind}") - val event = tempSigner.signNormal( - createdAt = unsignedNostrEvent.createdAt.epochSeconds, - kind = unsignedNostrEvent.kind, - tags = unsignedNostrEvent.tags, - content = unsignedNostrEvent.privateTags?.let { PrivateTagsInContent.encryptNip44(it, tempSigner) } ?: unsignedNostrEvent.content - ) - logger.d("Signed: ${event.toJson()}") + val tempSigner = NostrSignerSync( + keyPair + ) + scope.launch(Dispatchers.IO) { + logger.i { "observeUnsignedNostrEvents: ${keyPair.pubKey.toHexKey()}" } + nostrRepository.observeUnsignedNostrEvents( + publicKey = keyPair.pubKey.toHexKey() + ).distinctUntilChanged().collect { unsignedNostrEventOrNull -> + scope.launch(Dispatchers.IO) { + unsignedNostrEventOrNull?.let { unsignedNostrEvent -> + logger.d("Unsigned: ${unsignedNostrEvent.kind}") + val event = tempSigner.signNormal( + createdAt = unsignedNostrEvent.createdAt.epochSeconds, + kind = unsignedNostrEvent.kind, + tags = unsignedNostrEvent.tags, + content = unsignedNostrEvent.privateTags?.let { PrivateTagsInContent.encryptNip44(it, tempSigner) } ?: unsignedNostrEvent.content + ) + logger.d("Signed: ${event.toJson()}") - nostrRepository.publishNostrEvent( - unsignedNostrEvent, - _root_ide_package_.at.torch.compose.database.model.NostrEvent( - id = event.id, - pubKey = event.pubKey, - kind = event.kind, - tags = event.tags, - content = event.content, - createdAt = Instant.fromEpochSeconds(event.createdAt), - sig = event.sig, - unsignedNostrEventId = unsignedNostrEvent.id - ), - relayURLs = at.torch.compose.nostr.Relays.DefaultDMRelayList.map { normalizedRelayUrl -> normalizedRelayUrl.url }, - activeKeyPair = keyPair - ) - } + nostrRepository.publishNostrEvent( + unsignedNostrEvent, + _root_ide_package_.at.torch.compose.database.model.NostrEvent( + id = event.id, + pubKey = event.pubKey, + kind = event.kind, + tags = event.tags, + content = event.content, + createdAt = Instant.fromEpochSeconds(event.createdAt), + sig = event.sig, + unsignedNostrEventId = unsignedNostrEvent.id + ), + relayURLs = at.torch.compose.nostr.Relays.DefaultDMRelayList.map { normalizedRelayUrl -> normalizedRelayUrl.url }, + activeKeyPair = keyPair + ) } } } } + } private fun observeUnsealedGiftWrapPayloads( @@ -168,4 +169,29 @@ class NotaryViewModel( } } + + private fun observeActiveMarmotKeyPackageBundle( + keyPair: KeyPair + ) { + val tempSigner = NostrSignerSync( + keyPair + ) + scope.launch(Dispatchers.IO) { + logger.i { "observeActiveMarmotKeyPackageBundle: ${keyPair.pubKey.toHexKey()}" } + marmotRepository.observeActiveMarmotKeyPackageBundle( + publicKey = keyPair.pubKey.toHexKey() + ).distinctUntilChanged().collect { marmotKeyPackageBundleOrNull -> + scope.launch(Dispatchers.IO) { + if (marmotKeyPackageBundleOrNull == null) { + logger.d("Need to create new key package bundle") + marmotRepository.publishMarmotKeyPackageBundle( + publicKey = keyPair.pubKey.toHexKey(), + nsecPassword = "" // TODO: Implement nsecPassword logic... + ) + } + } + } + } + } + } \ No newline at end of file