diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraDao.kt index ac55e82e..bd8f5f82 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraDao.kt @@ -9,7 +9,6 @@ import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import press.mantra.compose.database.MantraDatabase import press.mantra.compose.database.model.ChatMessage -import press.mantra.compose.database.model.MantraArtifact import press.mantra.compose.database.model.MantraArtifactVersion import press.mantra.compose.database.model.MantraChapter import press.mantra.compose.database.model.MantraChunk @@ -19,7 +18,6 @@ import press.mantra.compose.database.model.MantraTranslationChapter import press.mantra.compose.database.model.MantraTranslationChunk import press.mantra.compose.database.model.MarmotInnerEvent import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.nostr.nip30303.ArtifactEvent import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent import press.mantra.compose.nostr.nip30303.ChapterEvent import press.mantra.compose.nostr.nip30303.ChunkEvent @@ -156,62 +154,6 @@ abstract class MantraDao( } - @Transaction - open suspend fun addArtifact( - localChatRoom: LocalChatRoom, - name: String, - url: String, - versionLabel: String, - dialectId: HexKey, - userPublicKey: HexKey, - visibility: String = DEFAULT_VISIBILITY, - license: String = DEFAULT_LICENSE, - ): MantraArtifact? { - // TODO: Verify the active user is an admin of the chat room before allowing this. - val artifactEventTemplate = ArtifactEvent.build( - name = name, - url = url, - visibility = visibility, - license = license, - dialectId = dialectId, - ) - - val mantraArtifact = MantraArtifact.fromArtifactEventTemplate( - artifactEventTemplate = artifactEventTemplate, - chatRoomId = localChatRoom.chatRoom.id, - userPublicKey = userPublicKey, - ) ?: return null - - // Persist the artifact locally and submit it to the group. - // - // This covers the "private" visibility case. Permissioned artifacts - // (published as a PublicMessage) and public artifacts (published as a - // plain nostr event) are not implemented yet. - return try { - database.mantraArtifactDao().upsert(mantraArtifact) - - submitToGroup( - chatRoomId = mantraArtifact.chatRoomId, - submitterPublicKey = userPublicKey, - payload = rumorOf(artifactEventTemplate, userPublicKey), - text = "Added $name to artifacts", - ) - - // Every artifact starts with an initial version. - addArtifactVersionInternal( - localChatRoom = localChatRoom, - artifactId = mantraArtifact.id, - versionLabel = versionLabel, - userPublicKey = userPublicKey, - ) - - mantraArtifact - } catch (error: Throwable) { - logger.e("Failed to add artifact \"$name\" to chat room ${localChatRoom.chatRoom.id}", error) - null - } - } - @Transaction open suspend fun addArtifactVersion( localChatRoom: LocalChatRoom, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index 00104db4..e250ab80 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -602,15 +602,17 @@ data class ChatMessage( ) } ArtifactEvent.KIND -> { + val artifactEvent = ArtifactEvent( + id = event.id, + pubKey = event.pubKey, + createdAt = event.createdAt, + tags = event.tags, + content = event.content, + sig = event.sig, + ) + MantraArtifact.fromArtifactEvent( - artifactEvent = ArtifactEvent( - id = event.id, - pubKey = event.pubKey, - createdAt = event.createdAt, - tags = event.tags, - content = event.content, - sig = event.sig, - ), + artifactEvent = artifactEvent, chatRoomId = groupId, )?.let { mantraArtifact -> database.mantraArtifactDao().upsert( @@ -619,6 +621,23 @@ data class ChatMessage( ) ) + // An artifact arrives with the version it starts life + // with, derived here rather than sent, so that every + // device holding the artifact holds the same first + // version. Nothing else can hang off an artifact until + // one exists -- a chapter attaches to a version, not to + // an artifact -- so an artifact without one is inert. + MantraArtifactVersion.initialVersionOf( + artifactEvent = artifactEvent, + chatRoomId = groupId, + )?.let { initialVersion -> + database.mantraArtifactVersionDao().upsert( + initialVersion.copy( + marmotGroupEventId = marmotGroupEventId, + ) + ) + } + ChatMessage( giftWrapPayloadId = null, messageType = "artifact", diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt index 1e7435ef..6cd5c209 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt @@ -10,6 +10,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip31Alts.AltTag import press.mantra.compose.database.model.traits.OptionalNostrEventEntity import press.mantra.compose.database.model.traits.TimestampedEntity +import press.mantra.compose.nostr.nip30303.ArtifactEvent import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag @@ -83,6 +84,42 @@ data class MantraArtifactVersion( } companion object { + /** + * The version an artifact starts life with, derived from the artifact. + * + * The group signs an artifact; it does not sign this. So the first + * version cannot be an event proposed on its own -- that would cost a + * second quorum for one form -- and it cannot be invented by whichever + * device notices the artifact first, because an invented id differs on + * every device holding the same artifact and none of them would agree + * about which version a chapter hangs off. Deriving it from the signed + * artifact's own fields gives every device the same row from the same + * bytes, which is the only property that matters here. + * + * It is a rumor -- empty signature -- because nobody signed it. What the + * group signed is the artifact that declares it. + * + * Null when the artifact declares no version, which is every artifact + * written before it did. + */ + fun initialVersionOf( + artifactEvent: ArtifactEvent, + chatRoomId: HexKey, + ): MantraArtifactVersion? { + val versionLabel = artifactEvent.versionLabel() ?: return null + + return fromArtifactVersionEventTemplate( + artifactVersionEventTemplate = ArtifactVersionEvent.build( + content = versionLabel, + createdAt = artifactEvent.createdAt, + ) { + addUnique(ArtifactIdTag.assemble(artifactEvent.id)) + }, + chatRoomId = chatRoomId, + userPublicKey = artifactEvent.pubKey, + ) + } + fun fromArtifactVersionEventTemplate( artifactVersionEventTemplate: EventTemplate, chatRoomId: HexKey, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt index eb573bcf..26b4ddf6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt @@ -131,28 +131,6 @@ class DatabaseMantraRepository( ) } - override suspend fun addArtifact( - localChatRoom: LocalChatRoom, - name: String, - url: String, - versionLabel: String, - dialectId: HexKey, - userPublicKey: HexKey, - visibility: String, - license: String, - ): MantraArtifact? { - return database.mantraDao().addArtifact( - localChatRoom = localChatRoom, - name = name, - url = url, - versionLabel = versionLabel, - dialectId = dialectId, - userPublicKey = userPublicKey, - visibility = visibility, - license = license - ) - } - override suspend fun addArtifactVersion( localChatRoom: LocalChatRoom, artifactId: HexKey, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ArtifactEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ArtifactEvent.kt index 6d0ebb96..1aaa6558 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ArtifactEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ArtifactEvent.kt @@ -9,6 +9,7 @@ import com.vitorpamplona.quartz.nip22Comments.RootScope import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils +import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag import press.mantra.compose.nostr.nip30303.tags.DialectIdTag import press.mantra.compose.nostr.nip30303.tags.LicenseTag import press.mantra.compose.nostr.nip30303.tags.UrlTag @@ -31,6 +32,17 @@ class ArtifactEvent( fun dialectIdReference() = tags.firstNotNullOfOrNull(DialectIdTag::parse)?.ref fun dialectId() = dialectIdReference()?.eventId + /** + * The label of the version this artifact starts life with. + * + * Carried on the artifact rather than in an event of its own because the + * group signs the artifact and nothing else. A first version proposed + * separately would need either a second quorum for one form or an id each + * device invents for itself, and invented ids differ on every device holding + * the same artifact. Null on artifacts written before this was declared. + */ + fun versionLabel() = tags.firstNotNullOfOrNull(ArtifactVersionMetadataTag::parse)?.versionLabel + companion object { const val KIND = 30300 const val ALT_DESCRIPTION = "Artifact" @@ -41,6 +53,7 @@ class ArtifactEvent( visibility: String, license: String, dialectId: String, + versionLabel: String, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, name, createdAt) { @@ -51,6 +64,7 @@ class ArtifactEvent( addUnique( DialectIdTag.assemble(dialectId) ) + addUnique(ArtifactVersionMetadataTag.assemble(versionLabel)) initializer() } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt index 9d0b5d88..5605c335 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt @@ -92,17 +92,6 @@ interface MantraRepository { userPublicKey: HexKey, ): MantraDialect? - suspend fun addArtifact( - localChatRoom: LocalChatRoom, - name: String, - url: String, - versionLabel: String, - dialectId: HexKey, - userPublicKey: HexKey, - visibility: String = DEFAULT_VISIBILITY, - license: String = DEFAULT_LICENSE, - ): MantraArtifact? - suspend fun addArtifactVersion( localChatRoom: LocalChatRoom, artifactId: HexKey, @@ -176,17 +165,6 @@ interface MantraRepository { userPublicKey: HexKey, ): MantraDialect? = null - override suspend fun addArtifact( - localChatRoom: LocalChatRoom, - name: String, - url: String, - versionLabel: String, - dialectId: HexKey, - userPublicKey: HexKey, - visibility: String, - license: String, - ): MantraArtifact? = null - override suspend fun addArtifactVersion( localChatRoom: LocalChatRoom, artifactId: HexKey, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt index 5d451f8d..10dc902c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt @@ -54,6 +54,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute import press.mantra.compose.ui.composable.navigation.routes.Route @@ -62,7 +63,7 @@ import press.mantra.compose.ui.theme.TorchTheme import press.mantra.compose.ui.view.state.ChatRoomMessagingUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.repository.MantraRepository -import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute +import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute import press.mantra.compose.ui.view.model.AddArtifactViewModel import press.mantra.compose.ui.view.state.AddArtifactUIState @@ -77,6 +78,7 @@ fun AddArtifactScreen( nostrRepository: NostrRepository, chatRepository: ChatRepository, mantraRepository: MantraRepository, + frostSigningRepository: FrostSigningRepository, onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, onNavigateToRoute: (Route) -> Unit, ) { @@ -88,7 +90,8 @@ fun AddArtifactScreen( nostrRepository = nostrRepository, chatRepository = chatRepository, activeUserPublicKey = activeUserPublicKey, - mantraRepository = mantraRepository + mantraRepository = mantraRepository, + frostSigningRepository = frostSigningRepository ) ) @@ -115,11 +118,12 @@ fun AddArtifactScreen( // one is picked; dialects are defined from the group detail screen. var selectedDialectId: String? by remember { mutableStateOf(null) } - // Of the required fields this is the only one the screen cannot ask - // for again: a dialect has to already exist, and nothing here can - // create one. So an unpicked dialect is a dead end rather than - // something to submit and be told about, and the button says so. - val canAddArtifact = selectedDialectId != null + // The two things the form cannot ask for again. A dialect has to + // already exist and nothing here can create one; a shared key has to + // have been ceremonied and this is not where that happens. Either + // missing is a dead end rather than something to propose and be told + // about afterwards, and the button says so. + val canAddArtifact = selectedDialectId != null && addArtifactUIState.canSign // M3 gives a FAB no `enabled`, so borrow the disabled colours every // other button in the app uses rather than inventing a shade here. @@ -168,15 +172,18 @@ fun AddArtifactScreen( urlField = urlFieldState, versionLabelField = versionLabelFieldState, dialectId = selectedDialectId, - onSuccess = { artifactId -> - // Open the newly created artifact, removing this - // add screen from the back stack. + onSuccess = { sessionId -> + // Onto the session rather than to the + // artifact. Nothing has been created yet + // -- the artifact appears when enough + // members sign -- so a detail screen for + // a row that does not exist would read as + // a failure. onNavigateToRouteAndPopUpInclusive.invoke( - ArtifactDetailRoute( + FrostSigningRoute( activeUserPublicKey = activeUserPublicKey, - artifactId = artifactId, chatRoomId = chatRoomId, - relayHint = relayHint + sessionId = sessionId ) ) }, @@ -191,9 +198,9 @@ fun AddArtifactScreen( ) { Icon( Icons.Default.Add, - contentDescription = "Add artifact" + contentDescription = "Propose artifact" ) - Text("Add Artifact") + Text("Propose Artifact") } } ) @@ -209,6 +216,16 @@ fun AddArtifactScreen( horizontalAlignment = Alignment.CenterHorizontally ) { Text("Add Artifact to the group library") + + if (!addArtifactUIState.canSign) { + Text( + text = "This group has no shared key, so it cannot sign an " + + "artifact into its library. Run a shared key ceremony first.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + OutlinedTextField( modifier = Modifier.fillMaxWidth() .background(BottomAppBarDefaults.containerColor), @@ -385,6 +402,7 @@ private fun AddArtifactScreenPreview() { nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, onNavigateToRouteAndPopUpInclusive = {}, onNavigateToRoute = {} ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt index 38282f75..0ee8f15f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt @@ -304,7 +304,15 @@ private fun WhatIsBeingSigned(event: Event?) { .joinToString(" · ") } - ArtifactEvent.KIND -> "New artifact" to event.content + ArtifactEvent.KIND -> "New artifact" to ArtifactEvent( + event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig + ).let { artifact -> + // The url is the substance of an artifact -- signing one is putting + // the group's name to what it points at -- so it goes next to the + // name rather than being left for the detail screen afterwards. + listOfNotNull(event.content, artifact.versionLabel(), artifact.url()) + .joinToString(" · ") + } ChapterEvent.KIND -> "New chapter" to ChapterEvent( event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index cc29ee3e..9332b751 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -830,9 +830,12 @@ fun MantraNavHost( nostrRepository = databaseNostrRepository, chatRepository = databaseChatRepository, mantraRepository = databaseMantraRepository, - onNavigateToRouteAndPopUpInclusive = { chatRoomDetailRoute -> + frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRouteAndPopUpInclusive = { signingRoute -> + // Replace this add screen so back returns to the group rather + // than to a form whose proposal has already gone out. navController.navigate( - route = chatRoomDetailRoute + route = signingRoute ) { popUpTo(route) { inclusive = true diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt index 98a02974..582698f0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt @@ -19,6 +19,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.launch import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.nip30303.ArtifactEvent +import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.MantraRepository import press.mantra.compose.ui.view.state.AddArtifactUIState @@ -30,6 +32,7 @@ class AddArtifactViewModel( val nostrRepository: NostrRepository, val chatRepository: ChatRepository, val mantraRepository: MantraRepository, + val frostSigningRepository: FrostSigningRepository, ): ViewModel() { var addArtifactUIState: AddArtifactUIState by mutableStateOf(initialAddArtifactUIState) @@ -52,11 +55,25 @@ class AddArtifactViewModel( AddArtifactUIState.Loaded( localChatRoom = localChatRoom, dialects = mantraRepository.getDialects(localChatRoom.chatRoom.id), + canSign = frostSigningRepository.canSign(chatRoomId), ) } } } + /** + * Asks the group to sign an artifact into its library. + * + * The artifact is not created here and does not exist yet. What goes out is + * a proposal to sign it, and the artifact appears -- on every member's + * device at once, authored by the group's shared key rather than by whoever + * typed it -- when enough members have signed. + * + * That is the difference from submitting one. A submission says "I am + * putting this in front of the group" and the group's only recourse + * afterwards is social. A signature is the group saying it, and it takes a + * quorum to say. A library is the group's, so the second is the honest one. + */ fun addArtifact( localChatRoom: LocalChatRoom, nameField: TextFieldState, @@ -65,57 +82,65 @@ class AddArtifactViewModel( dialectId: HexKey?, visibility: String = MantraRepository.DEFAULT_VISIBILITY, license: String = MantraRepository.DEFAULT_LICENSE, - onSuccess: (artifactId: String) -> Unit, + onSuccess: (sessionId: String) -> Unit, onFailure: () -> Unit ) { - localChatRoom.chatRoom.toMlsGroup()?.let { mlsGroup -> - val name = nameField.text.toString() - val url = urlField.text.toString() - val versionLabel = versionLabelField.text.toString() + val name = nameField.text.toString() + val url = urlField.text.toString() + val versionLabel = versionLabelField.text.toString() - // addArtifact requires an existing source dialect; they are defined - // from the group detail screen, not here. - if (name.isBlank() || url.isBlank() || versionLabel.isBlank() || dialectId.isNullOrBlank()) { - onFailure.invoke() - return - } + // An artifact requires an existing source dialect; they are signed into + // existence from the group detail screen, not here. + if (name.isBlank() || url.isBlank() || versionLabel.isBlank() || dialectId.isNullOrBlank()) { + onFailure.invoke() + return + } - // Guard against double submits from repeated FAB taps. - if (isActionPending.value) return - isActionPending.value = true + // Guard against double submits from repeated FAB taps. + if (isActionPending.value) return + isActionPending.value = true - viewModelScope.launch(Dispatchers.IO) { - val artifact = runCatching { - mantraRepository.addArtifact( - localChatRoom = localChatRoom, - name = name, - url = url, - versionLabel = versionLabel, - dialectId = dialectId, - userPublicKey = activeUserPublicKey, - visibility = visibility, - license = license, - ) - }.onFailure { error -> - logger.e("Failed to add artifact", error) - }.getOrNull() + viewModelScope.launch(Dispatchers.IO) { + // The version label rides on the artifact rather than following it as + // a second event. The group signs the artifact; a first version + // proposed on its own would cost a second quorum for one form, and + // every device derives the same first version from what was signed. + val artifactEventTemplate = ArtifactEvent.build( + name = name, + url = url, + visibility = visibility, + license = license, + dialectId = dialectId, + versionLabel = versionLabel, + ) - if (artifact != null) { - nameField.clearText() - urlField.clearText() - versionLabelField.clearText() + val session = runCatching { + frostSigningRepository.proposeSigning( + localChatRoom = localChatRoom, + userPublicKey = activeUserPublicKey, + kind = artifactEventTemplate.kind, + tags = artifactEventTemplate.tags, + content = artifactEventTemplate.content, + ) + }.onFailure { error -> + logger.e("Failed to propose an artifact for signing", error) + }.getOrNull() - viewModelScope.launch(Dispatchers.Main) { - onSuccess.invoke(artifact.id) - } - } else { - viewModelScope.launch(Dispatchers.Main) { - onFailure.invoke() - } + if (session != null) { + nameField.clearText() + urlField.clearText() + versionLabelField.clearText() + + viewModelScope.launch(Dispatchers.Main) { + onSuccess.invoke(session.id) + } + } else { + viewModelScope.launch(Dispatchers.Main) { + onFailure.invoke() } - - isActionPending.value = false } + + isActionPending.value = false } } @@ -129,7 +154,8 @@ class AddArtifactViewModel( initialAddArtifactUIState: AddArtifactUIState = AddArtifactUIState.Loading, nostrRepository: NostrRepository, chatRepository: ChatRepository, - mantraRepository: MantraRepository + mantraRepository: MantraRepository, + frostSigningRepository: FrostSigningRepository ): ViewModelProvider.Factory = viewModelFactory { initializer { AddArtifactViewModel( @@ -139,9 +165,10 @@ class AddArtifactViewModel( initialAddArtifactUIState = initialAddArtifactUIState, nostrRepository = nostrRepository, chatRepository = chatRepository, - mantraRepository = mantraRepository + mantraRepository = mantraRepository, + frostSigningRepository = frostSigningRepository ) } } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddArtifactUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddArtifactUIState.kt index af593edb..32fe947f 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddArtifactUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddArtifactUIState.kt @@ -7,6 +7,13 @@ sealed interface AddArtifactUIState { data class Loaded( val localChatRoom: LocalChatRoom, val dialects: List = emptyList(), + + /** + * Whether the group holds a shared key. An artifact is signed into + * existence now rather than submitted, so a group without one cannot + * add one here at all. + */ + val canSign: Boolean = false, ): AddArtifactUIState data class Error( @@ -15,4 +22,3 @@ sealed interface AddArtifactUIState { data object Loading: AddArtifactUIState } - diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/InitialArtifactVersionTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/InitialArtifactVersionTest.kt new file mode 100644 index 00000000..c59e65ff --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/InitialArtifactVersionTest.kt @@ -0,0 +1,144 @@ +package press.mantra.compose.database.model + +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import press.mantra.compose.nostr.nip30303.ArtifactEvent +import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag + +/** + * The first version of an artifact is derived, not delivered. + * + * The group signs an artifact and nothing else, so the version it starts life + * with is not an event anybody sent: every device builds the row for itself out + * of the artifact it already holds. That only works while every device builds + * the *same* row, and nothing about the ids would show it if they stopped — + * they are content hashes, opaque hex either way. What would show is a group + * that quietly disagrees about which version a chapter hangs off, with the + * artifact looking identical on every screen. + */ +class InitialArtifactVersionTest { + private val groupKey = "a".repeat(64) + private val dialectId = "b".repeat(64) + private val chatRoomId = "room" + + private fun signedArtifact( + name: String = "In Detention", + versionLabel: String = "1.0", + createdAt: Long = 1_700_000_000, + ): ArtifactEvent { + val template = ArtifactEvent.build( + name = name, + url = "example.com", + visibility = "private", + license = "cc", + dialectId = dialectId, + versionLabel = versionLabel, + createdAt = createdAt, + ) + + // Hashed rather than made up, so two fixtures that differ are two + // different artifacts here for the same reason they would be in the app. + return ArtifactEvent( + id = EventHasher.hashId( + pubKey = groupKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content + ), + pubKey = groupKey, + createdAt = template.createdAt, + tags = template.tags, + content = template.content, + sig = "d".repeat(128) + ) + } + + @Test + fun `the derived version is a function of the artifact and nothing else`() { + // Every input has to come off the artifact. Reading the clock here would + // still agree with itself twice in a row -- and disagree between two + // devices that applied the same artifact minutes apart, which is the + // case nobody can reproduce on demand. So the timestamp is checked + // against the artifact's rather than against a second derivation. + val artifact = signedArtifact(createdAt = 1_700_000_000) + + val version = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId) + + assertNotNull(version) + assertEquals(1_700_000_000, version.createdAt.epochSeconds) + } + + @Test + fun `two devices derive the same first version from the same artifact`() { + val artifact = signedArtifact() + + val mine = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId) + val theirs = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId) + + assertNotNull(mine) + assertEquals(mine.id, theirs?.id) + assertEquals(mine.createdAt, theirs?.createdAt) + } + + @Test + fun `an artifact signed at a different moment derives a different version`() { + // The artifact's own timestamp is bound into the derived id, so two + // proposals identical but for when they were made stay two artifacts + // with two first versions rather than colliding on one row. + val first = MantraArtifactVersion.initialVersionOf(signedArtifact(createdAt = 1_700_000_000), chatRoomId) + val second = MantraArtifactVersion.initialVersionOf(signedArtifact(createdAt = 1_700_000_001), chatRoomId) + + assertNotNull(first) + assertNotNull(second) + assertNotEquals(first.id, second.id) + } + + @Test + fun `the derived version hangs off the artifact and carries what it declared`() { + val artifact = signedArtifact(versionLabel = "First Edition") + + val version = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId) + + assertEquals(artifact.id, version?.artifactId) + assertEquals("First Edition", version?.versionLabel) + // Authored by whoever authored the artifact -- the group, once signed -- + // and unsigned, because nobody signed this. + assertEquals(groupKey, version?.publicKey) + assertEquals("", version?.signature) + } + + @Test + fun `the label is bound into the id rather than hung beside it`() { + // Two artifacts alike but for the label must not derive one version + // between them: the id has to come from the whole event, or a group + // renaming a version would leave the row it replaces in place. + val first = MantraArtifactVersion.initialVersionOf(signedArtifact(versionLabel = "1.0"), chatRoomId) + val second = MantraArtifactVersion.initialVersionOf(signedArtifact(versionLabel = "2.0"), chatRoomId) + + assertNotNull(first) + assertNotNull(second) + assertNotEquals(first.id, second.id) + } + + @Test + fun `an artifact that declares no version derives none`() { + // Artifacts written before the artifact carried its first version. + val declared = signedArtifact() + val silent = ArtifactEvent( + id = declared.id, + pubKey = declared.pubKey, + createdAt = declared.createdAt, + tags = declared.tags.filterNot { it.firstOrNull() == ArtifactVersionMetadataTag.TAG_NAME } + .toTypedArray(), + content = declared.content, + sig = declared.sig + ) + + assertNull(MantraArtifactVersion.initialVersionOf(silent, chatRoomId)) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/RumorIdAgreementTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/RumorIdAgreementTest.kt index ea0437ab..eb5572f0 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/RumorIdAgreementTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/RumorIdAgreementTest.kt @@ -66,7 +66,8 @@ class RumorIdAgreementTest { url = "example.com", visibility = "private", license = "cc", - dialectId = other + dialectId = other, + versionLabel = "1.0" ) val entity = MantraArtifact.fromArtifactEventTemplate( diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt new file mode 100644 index 00000000..f585390b --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt @@ -0,0 +1,227 @@ +package press.mantra.compose.managers + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.crypto.frost.Frost +import fr.acinq.bitcoin.crypto.frost.IndividualNonce +import fr.acinq.bitcoin.crypto.frost.KeyMaterial +import fr.acinq.bitcoin.crypto.frost.SecretNonce +import fr.acinq.bitcoin.crypto.frost.Session +import fr.acinq.bitcoin.crypto.frost.TweakCache +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.MantraArtifact +import press.mantra.compose.database.model.MantraArtifactVersion +import press.mantra.compose.extensions.toHex +import press.mantra.compose.nostr.nip30303.ArtifactEvent + +/** + * An artifact the group signed, from proposal to rows, against real FROST. + * + * Adding an artifact used to write a row and submit it; the row said the + * submitter wrote it, because they had. Now the group signs it, and the claim + * this test exists to hold is that the artifact every device ends up with is + * the group's: authored by the threshold key, carrying a signature that + * verifies, with an id every member arrives at independently. + * + * None of that is visible when it breaks. A row whose author is the proposer + * looks exactly like a row whose author is the group -- both are opaque hex -- + * and a group that disagrees about the id has two artifacts that look like one. + */ +class SignedArtifactTest { + private val participants = 3 + private val threshold = 2 + private val chatRoomId = "room" + + /** The member who filled in the form. Nothing they own should end up on the row. */ + private val proposer = "9".repeat(64) + private val dialectId = "b".repeat(64) + + /** Stands in for a completed ceremony; the test is about what gets signed, not the DKG. */ + private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1") + ), + nParticipants = participants, + threshold = threshold + ) + + private val tweakCache: TweakCache = TweakCache.create(keyMaterial.thresholdPublicKey) + + /** The group's nostr identity: the x-only key a BIP-340 signature verifies against. */ + private val groupPubKey = tweakCache.tweakedPublicKey.value.toHex() + + private fun proposalTemplate(versionLabel: String = "1.0") = ArtifactEvent.build( + name = "In Detention", + url = "https://example.com/in-detention", + visibility = "private", + license = "cc", + dialectId = dialectId, + versionLabel = versionLabel, + createdAt = 1_700_000_000L, + ) + + /** + * Exactly what `FrostSigningManager.unsignedEventOf` does, and it must stay + * exactly that: the proposer's fields re-authored under the group's key. + */ + private fun unsignedEventOf(template: com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<*>) = Event( + id = EventHasher.hashId( + pubKey = groupPubKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content + ), + pubKey = groupPubKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = "" + ) + + private fun sessionOver(unsignedEvent: Event) = FrostSigningSession( + id = "s".repeat(64), + chatRoomId = chatRoomId, + coordinatorPublicKey = proposer, + userPublicKey = proposer, + dkgSessionId = "k".repeat(64), + threshold = threshold, + participantCount = participants, + signerId = 0, + unsignedEventJson = unsignedEvent.toJson(), + eventId = unsignedEvent.id, + nonceRandom = "f".repeat(64) + ) + + /** A quorum signing the session's event, in the manager's order. */ + private fun groupSignature(session: FrostSigningSession): String { + val message = ByteVector(session.eventId.hexToByteArray()) + val signerIds = listOf(0, 1) + + val nonces = signerIds.map { signerId -> + SecretNonce.generate( + sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"), + secretShare = keyMaterial.secretShares[signerId], + publicShare = keyMaterial.publicShares[signerId], + tweakedThresholdPublicKey = tweakCache.tweakedPublicKey, + message = message, + extraInput = null + ) + } + + val signingSession = Session.create( + aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!, + signerIds = signerIds.map { it.toUInt() }, + signerPublicShares = signerIds.map { keyMaterial.publicShares[it] }, + nParticipants = participants, + threshold = threshold, + tweakCache = tweakCache, + message = message + ) + + val partials = signerIds.mapIndexed { position, signerId -> + signingSession.sign( + nonces[position].first, + keyMaterial.secretShares[signerId], + signerId.toUInt() + ).right!! + } + + return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex() + } + + /** Everything from the form to the row a device holds afterwards. */ + private fun signedArtifactEvent(versionLabel: String = "1.0"): ArtifactEvent { + val session = sessionOver(unsignedEventOf(proposalTemplate(versionLabel))) + val signed = FrostSigningManager.signedEvent(session, groupSignature(session)) + + return ArtifactEvent( + signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig + ) + } + + @Test + fun `the artifact the group signs is authored by the group, not the proposer`() { + val artifact = MantraArtifact.fromArtifactEvent(signedArtifactEvent(), chatRoomId) + + assertNotNull(artifact) + assertEquals(groupPubKey, artifact.publicKey) + assertNotEquals(proposer, artifact.publicKey) + } + + @Test + fun `the row's id is the id the group put its signature to`() { + // Every device builds this row from the same signed event, so the id has + // to be the one that was signed rather than anything recomputed from the + // proposer. Otherwise members converge on nothing and each holds its own + // copy of what is meant to be one artifact. + val session = sessionOver(unsignedEventOf(proposalTemplate())) + val signed = FrostSigningManager.signedEvent(session, groupSignature(session)) + + val artifact = MantraArtifact.fromArtifactEvent( + ArtifactEvent(signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig), + chatRoomId + ) + + assertEquals(session.eventId, artifact?.id) + } + + @Test + fun `the signature on the row verifies against the row's own id and author`() { + // The payoff of signing rather than submitting: the row carries proof the + // group made it, checkable by anybody holding it. + val artifact = MantraArtifact.fromArtifactEvent(signedArtifactEvent(), chatRoomId) + + assertNotNull(artifact) + assertTrue( + Nip01Crypto.verify( + signature = artifact.signature.hexToByteArray(), + hash = artifact.id.hexToByteArray(), + pubKey = artifact.publicKey.hexToByteArray() + ), + "an artifact row should carry a signature the group's key made over its own id" + ) + } + + @Test + fun `what the form asked for is what the group signed`() { + // The fields travel as tags through a session that knows nothing about + // artifacts. Anything dropped in there is signed away silently. + val artifact = MantraArtifact.fromArtifactEvent(signedArtifactEvent(), chatRoomId) + + assertEquals("In Detention", artifact?.name) + assertEquals("https://example.com/in-detention", artifact?.url) + assertEquals("private", artifact?.visibility) + assertEquals("cc", artifact?.license) + assertEquals(dialectId, artifact?.dialectId) + } + + @Test + fun `the artifact arrives with the first version hanging off it`() { + // Nothing sends this row: each device derives it from the artifact it + // just applied. A chapter attaches to a version rather than to an + // artifact, so an artifact that arrives without one is inert. + val signed = signedArtifactEvent(versionLabel = "First Edition") + + val artifact = MantraArtifact.fromArtifactEvent(signed, chatRoomId) + val version = MantraArtifactVersion.initialVersionOf(signed, chatRoomId) + + assertNotNull(version) + assertEquals(artifact?.id, version.artifactId) + assertEquals("First Edition", version.versionLabel) + assertEquals(groupPubKey, version.publicKey) + // Derived, not signed: the group signed the artifact that declares it. + assertEquals("", version.signature) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ArtifactEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ArtifactEventTest.kt new file mode 100644 index 00000000..db212a4a --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ArtifactEventTest.kt @@ -0,0 +1,118 @@ +package press.mantra.compose.nostr.nip30303 + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag + +/** + * What the form collects has to reach the members deciding whether to sign it. + * + * An artifact proposal leaves the proposer's device as a kind, a tag array and + * a string, and everything a member is shown before signing -- and every row + * built afterwards -- is read back out of those. A field that does not survive + * the trip is not a visible failure: the artifact still appears, just without + * a url, or a source dialect, or a first version, on every device but the one + * that typed it. + */ +class ArtifactEventTest { + private val groupKey = "a".repeat(64) + private val dialectId = "b".repeat(64) + + /** The event a member actually receives: bytes, with no template behind it. */ + private fun readBack(template: com.vitorpamplona.quartz.nip01Core.signers.EventTemplate) = + ArtifactEvent( + id = EventHasher.hashId( + pubKey = groupKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + ), + pubKey = groupKey, + createdAt = template.createdAt, + tags = template.tags, + content = template.content, + sig = "c".repeat(128), + ) + + private fun proposal(versionLabel: String = "1.0") = ArtifactEvent.build( + name = "In Detention", + url = "https://example.com/in-detention", + visibility = "private", + license = "cc", + dialectId = dialectId, + versionLabel = versionLabel, + createdAt = 1_700_000_000L, + ) + + @Test + fun `every field the form collects survives the trip through the tags`() { + val artifact = readBack(proposal()) + + assertEquals("In Detention", artifact.content) + assertEquals("https://example.com/in-detention", artifact.url()) + assertEquals("private", artifact.visibility()) + assertEquals("cc", artifact.license()) + assertEquals(dialectId, artifact.dialectId()) + assertEquals("1.0", artifact.versionLabel()) + } + + @Test + fun `the proposal is the artifact's kind, so the signing screen can describe it`() { + // The session carries a kind and nothing else to go on. Get this wrong + // and members are asked to sign "event of kind 30300" -- a question + // nobody can answer. + assertEquals(ArtifactEvent.KIND, proposal().kind) + } + + @Test + fun `an artifact declares one version, whatever an initializer adds`() { + // addUnique, not add: two labels would leave receivers deriving two + // different first versions depending on which one they read first. + val template = ArtifactEvent.build( + name = "In Detention", + url = "https://example.com/in-detention", + visibility = "private", + license = "cc", + dialectId = dialectId, + versionLabel = "1.0", + ) { + addUnique(ArtifactVersionMetadataTag.assemble("2.0")) + } + + assertEquals( + 1, + template.tags.count { it.firstOrNull() == ArtifactVersionMetadataTag.TAG_NAME } + ) + } + + @Test + fun `an artifact from before the label existed reads back as declaring none`() { + // Not an error: it is what every artifact submitted the old way looks + // like, and they have to keep parsing rather than failing to load. + val template = proposal() + val older = Event( + id = "d".repeat(64), + pubKey = groupKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags + .filterNot { it.firstOrNull() == ArtifactVersionMetadataTag.TAG_NAME } + .toTypedArray(), + content = template.content, + sig = "", + ) + + val artifact = ArtifactEvent( + older.id, older.pubKey, older.createdAt, older.tags, older.content, older.sig + ) + + assertNull(artifact.versionLabel()) + // Everything else still reads, so the artifact itself is unharmed. + assertEquals(dialectId, artifact.dialectId()) + assertEquals("https://example.com/in-detention", artifact.url()) + } +}