Merge branch 'mantra' into claude/marmot-direct-message-type-7a0473

Twenty-two commits had landed on mantra since this branch left it, several
of them in the same files. Merged this way round so mantra stayed untouched
until the result compiled and its tests passed.

The migration had to be renumbered, and this is the conflict that mattered.
mantra is at database version 7 and already has its own 5.json -- for
MarmotInnerEvent.payloadEventId, nothing to do with direct messages. This
branch had also written a 5.json, for a different schema. Resolved by
restoring mantra's 5.json untouched and moving the direct message columns
to an AutoMigration(7, 8) with a regenerated 8.json. Taking either 5.json
over the other would have left every device validating a migration chain
against a schema it was never built from; keeping version = 5 would have
made a v7 install refuse to open at all.

The regenerated 8.json is two ADD COLUMNs and nothing else, same as before.

fromGroupEventResult was restructured on mantra: the kind switch moved into
applyInnerEvent, and a SubmissionEvent envelope now wraps nip30303 payloads.
Took that structure and re-applied the direct message branch ahead of it
rather than inside it -- a gift wrap is not a nip30303 payload to apply, and
what happens to it depends only on whether this device's key opens it, so it
does not belong in a function about applying submissions.

The isUserMessage fix was re-applied to the eight call sites mantra's
version has, up from the six it had here.

ChatMessageListViewModel and ChatRoomMessagingScreen took mantra's versions
with the composer state, the two renderings and the reply action layered
back on.

docs/README.md keeps both new rows and mantra's closing note about the
skipped-keys document.

108 tests pass, up from 50 here and 83 on mantra.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-05 23:45:08 +02:00
72 changed files with 22707 additions and 748 deletions

View File

@@ -16,9 +16,11 @@ import press.mantra.compose.database.dao.ChatMessageNostrEventRelationDao
import press.mantra.compose.database.dao.ChatRoomDao
import press.mantra.compose.database.dao.ConnectionDao
import press.mantra.compose.database.dao.DkgSessionDao
import press.mantra.compose.database.dao.FrostSigningSessionDao
import press.mantra.compose.database.dao.GiftWrapMessageDao
import press.mantra.compose.database.dao.GiftWrapPayloadDao
import press.mantra.compose.database.dao.GiftWrapSealDao
import press.mantra.compose.database.dao.GroupKeyStateDao
import press.mantra.compose.database.dao.InReplyToRelationDao
import press.mantra.compose.database.dao.MantraArtifactDao
import press.mantra.compose.database.dao.MantraArtifactVersionDao
@@ -70,6 +72,7 @@ import press.mantra.compose.database.model.Connection
import press.mantra.compose.database.model.GiftWrapMessage
import press.mantra.compose.database.model.GiftWrapPayload
import press.mantra.compose.database.model.GiftWrapSeal
import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.database.model.InReplyToRelation
import press.mantra.compose.database.model.MantraArtifact
import press.mantra.compose.database.model.MantraArtifactVersion
@@ -102,6 +105,8 @@ import press.mantra.compose.database.model.Reaction
import press.mantra.compose.database.model.RecentSearch
import press.mantra.compose.database.model.DkgParticipantMessage
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.FrostSignerMessage
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.Relay
import press.mantra.compose.database.model.RepostedRelation
import press.mantra.compose.database.model.SynchronizeNostrEventRequest
@@ -124,9 +129,12 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
ChatRoom::class,
DkgParticipantMessage::class,
DkgSession::class,
FrostSignerMessage::class,
FrostSigningSession::class,
GiftWrapMessage::class,
GiftWrapSeal::class,
GiftWrapPayload::class,
GroupKeyState::class,
InReplyToRelation::class,
MantraArtifact::class,
MantraArtifactVersion::class,
@@ -164,7 +172,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
UnsignedNostrEvent::class,
Zap::class
],
version = 5,
version = 8,
autoMigrations = [
// v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can
// generate the migration itself — nothing existing changes shape.
@@ -178,11 +186,29 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
// into one type per ritual step. Data, not shape, so it is a manual
// migration passed to the builder rather than an entry here. See
// MIGRATION_3_4.
// v5 adds a nullable direct message recipient to MarmotInnerEvent and
//
// v5 adds the nullable MarmotInnerEvent.payloadEventId, which names the
// nip30303 event a SubmissionEvent rumor carries. Rumors queued before
// this come back null, which reads as "not a submission" -- correct,
// since none of them were.
AutoMigration(from = 4, to = 5),
// v6 adds the FrostSigningSession/FrostSignerMessage tables and the
// nullable DkgSession.publicShares. New tables and a nullable column are
// both shapes Room can migrate itself. A ceremony that completed before
// this reads back null, and signing falls back to not cross-checking
// shares rather than refusing to run.
AutoMigration(from = 5, to = 6),
// v7 adds the GroupKeyState table, which records what shared key a room
// signs with instead of leaving it to be rederived. A new table is a
// shape Room migrates itself. Rooms created before this have no row and
// fall back to the rederivation scan in FrostSigningManager.completedKey,
// which is why that scan stays.
AutoMigration(from = 6, to = 7),
// v8 adds a nullable direct message recipient to MarmotInnerEvent and
// ChatMessage. Nullable additions need no default and drop no data, so Room
// generates this one. Rows written before it come back null, which reads as
// "not a direct message" -- the only answer that is true of all of them.
AutoMigration(from = 4, to = 5),
AutoMigration(from = 7, to = 8)
]
)
@ColumnTypeConverters(MantraConverters::class)
@@ -202,6 +228,10 @@ abstract class MantraDatabase: RoomDatabase() {
abstract fun dkgSessionDao(): DkgSessionDao
abstract fun frostSigningSessionDao(): FrostSigningSessionDao
abstract fun groupKeyStateDao(): GroupKeyStateDao
abstract fun connectionDao(): ConnectionDao
abstract fun giftWrapMessageDao(): GiftWrapMessageDao

View File

@@ -27,6 +27,16 @@ interface DkgSessionDao {
@Query("SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC LIMIT 1")
suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession?
/**
* Every ceremony this device came out of holding a share, newest first.
*
* Signing happens in the #admins room, whose id is derived from the key
* rather than from the room the ceremony ran in, so the key is found by
* matching that derivation rather than by a stored room id.
*/
@Query("SELECT * FROM DkgSession WHERE thresholdPublicKey IS NOT NULL AND secretShare IS NOT NULL ORDER BY createdAt DESC")
suspend fun getKeyHoldingSessions(): List<DkgSession>
@Upsert
suspend fun upsert(dkgSession: DkgSession)

View File

@@ -0,0 +1,50 @@
package press.mantra.compose.database.dao
import androidx.room3.Dao
import androidx.room3.Query
import androidx.room3.Upsert
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import kotlinx.coroutines.flow.Flow
import press.mantra.compose.database.model.FrostSignerMessage
import press.mantra.compose.database.model.FrostSigningSession
@Dao
interface FrostSigningSessionDao {
@Query("SELECT * FROM FrostSigningSession WHERE id = :sessionId")
suspend fun getSessionById(sessionId: String): FrostSigningSession?
@Query("SELECT * FROM FrostSigningSession WHERE id = :sessionId")
fun observeSessionById(sessionId: String): Flow<FrostSigningSession?>
/**
* A room's signing sessions, newest first. Unlike a DKG a group signs
* repeatedly, so there is no single "current" one to observe.
*/
@Query("SELECT * FROM FrostSigningSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC")
fun observeSessionsForChatRoom(chatRoomId: String): Flow<List<FrostSigningSession>>
@Query("SELECT * FROM FrostSigningSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC LIMIT 1")
suspend fun getLatestSessionForChatRoom(chatRoomId: String): FrostSigningSession?
@Query("SELECT * FROM FrostSigningSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC")
suspend fun getSessionsForChatRoom(chatRoomId: String): List<FrostSigningSession>
@Upsert
suspend fun upsert(frostSigningSession: FrostSigningSession)
@Upsert
suspend fun upsert(frostSignerMessage: FrostSignerMessage)
@Query("SELECT * FROM FrostSignerMessage WHERE sessionId = :sessionId AND kind = :kind ORDER BY createdAt ASC")
suspend fun getMessagesByKind(sessionId: String, kind: Kind): List<FrostSignerMessage>
@Query("SELECT * FROM FrostSignerMessage WHERE sessionId = :sessionId ORDER BY createdAt ASC")
fun observeMessages(sessionId: String): Flow<List<FrostSignerMessage>>
@Query("SELECT COUNT(*) FROM FrostSignerMessage WHERE sessionId = :sessionId AND kind = :kind")
suspend fun countMessagesByKind(sessionId: String, kind: Kind): Int
@Query("SELECT * FROM FrostSignerMessage WHERE sessionId = :sessionId AND kind = :kind AND signerPublicKey = :signerPublicKey")
suspend fun getMessage(sessionId: String, kind: Kind, signerPublicKey: HexKey): FrostSignerMessage?
}

View File

@@ -0,0 +1,56 @@
package press.mantra.compose.database.dao
import androidx.room3.Dao
import androidx.room3.Query
import androidx.room3.Transaction
import androidx.room3.Upsert
import kotlinx.coroutines.flow.Flow
import press.mantra.compose.database.model.GroupKeyState
@Dao
abstract class GroupKeyStateDao {
@Query("SELECT * FROM GroupKeyState WHERE chatRoomId = :chatRoomId")
abstract suspend fun getByChatRoomId(chatRoomId: String): GroupKeyState?
@Query("SELECT * FROM GroupKeyState WHERE chatRoomId = :chatRoomId")
abstract fun observeByChatRoomId(chatRoomId: String): Flow<GroupKeyState?>
/** Every room that signs with one ceremony's key. One, today. */
@Query("SELECT * FROM GroupKeyState WHERE dkgSessionId = :dkgSessionId")
abstract suspend fun getByDkgSessionId(dkgSessionId: String): List<GroupKeyState>
@Upsert
abstract suspend fun upsert(groupKeyState: GroupKeyState)
/**
* Files a state, keeping the newest announcement per room.
*
* This is where the event's replaceable semantics actually happen. Relays
* never see a `GroupKeyStateEvent` -- it is a rumor inside a Marmot group
* event -- so nothing upstream applies the 3xxxx replacement rule, and an
* announcement that arrives twice would otherwise be two rows racing for
* one primary key.
*
* Older announcements are dropped rather than applied, so a redelivery from
* a relay backfill cannot walk the room back to a state it has already
* moved past. An announcement at the same instant is kept as a no-op: two
* members announcing the same true thing agree by construction, since both
* derived it from the room they are standing in.
*
* Returns the state now on file.
*/
@Transaction
open suspend fun replace(groupKeyState: GroupKeyState): GroupKeyState {
val known = getByChatRoomId(groupKeyState.chatRoomId)
if (known != null && known.announcedAt >= groupKeyState.announcedAt) return known
val stamped = groupKeyState.copy(
createdAt = known?.createdAt ?: groupKeyState.createdAt,
updatedAt = groupKeyState.createdAt
)
upsert(stamped)
return stamped
}
}

View File

@@ -3,7 +3,10 @@ package press.mantra.compose.database.dao
import androidx.room3.Dao
import androidx.room3.Transaction
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
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
@@ -21,6 +24,7 @@ import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.ChapterEvent
import press.mantra.compose.nostr.nip30303.ChunkEvent
import press.mantra.compose.nostr.nip30303.DialectEvent
import press.mantra.compose.nostr.nip30303.SubmissionEvent
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
@@ -28,6 +32,7 @@ import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag
import press.mantra.compose.repository.MantraRepository.Companion.DEFAULT_LICENSE
import press.mantra.compose.repository.MantraRepository.Companion.DEFAULT_VISIBILITY
import press.mantra.compose.text.Markdown
import kotlin.time.Instant
@Dao
abstract class MantraDao(
@@ -35,6 +40,82 @@ abstract class MantraDao(
) {
val logger = Logger.withTag("NostrDao")
/**
* The unsigned nip30303 event [template] describes, authored by [publicKey].
*
* Its id is computed the same way the matching Mantra* entity computes
* its own, so the row on disk and the payload on the wire are the same
* event rather than two copies of one.
*/
private fun rumorOf(
template: EventTemplate<out Event>,
publicKey: HexKey,
): Event = Event(
id = EventHasher.hashId(
pubKey = publicKey,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content,
),
pubKey = publicKey,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content,
// A rumor. What signs for this reaching the group is the kind:445 the
// outbound pipeline wraps it in, not the payload itself.
sig = "",
)
/**
* Queue [payload] for the group inside a [SubmissionEvent] that
* [submitterPublicKey] authors.
*
* nip30303 events are never sent on their own. Wrapping them means the
* payload keeps whoever wrote it as its author while the group still knows
* which member put it there -- see [SubmissionEvent] for why the two are
* worth telling apart.
*
* The submission is stored as an unprocessed rumor (null marmotGroupEventId),
* which is what the outbound pipeline picks up and encrypts into a kind:445
* group event for the chat room.
*/
private suspend fun submitToGroup(
chatRoomId: String,
submitterPublicKey: HexKey,
payload: Event,
text: String,
): MarmotInnerEvent {
val submissionTemplate = SubmissionEvent.build(payload = payload)
val submissionInnerEvent = MarmotInnerEvent(
id = EventHasher.hashId(
pubKey = submitterPublicKey,
createdAt = submissionTemplate.createdAt,
kind = submissionTemplate.kind,
tags = submissionTemplate.tags,
content = submissionTemplate.content,
),
publicKey = submitterPublicKey,
kind = SubmissionEvent.KIND,
createdAt = Instant.fromEpochSeconds(submissionTemplate.createdAt),
tags = submissionTemplate.tags,
content = submissionTemplate.content,
payloadEventId = payload.id,
chatRoomId = chatRoomId,
)
sendMarmotInnerEvent(
chatRoomId = chatRoomId,
userPublicKey = submitterPublicKey,
text = text,
marmotInnerEvent = submissionInnerEvent,
)
return submissionInnerEvent
}
@Transaction
open suspend fun addDialect(
localChatRoom: LocalChatRoom,
@@ -42,7 +123,7 @@ abstract class MantraDao(
country: String,
language: String,
userPublicKey: HexKey,
): MarmotInnerEvent? {
): MantraDialect? {
logger.d("addDialect: $name")
val dialectEventTemplate = DialectEvent.build(
@@ -57,26 +138,17 @@ abstract class MantraDao(
userPublicKey = userPublicKey,
) ?: return null
val dialectInnerEvent = MarmotInnerEvent(
id = mantraDialect.id,
publicKey = mantraDialect.publicKey,
kind = DialectEvent.KIND,
createdAt = mantraDialect.createdAt,
tags = dialectEventTemplate.tags,
content = dialectEventTemplate.content,
chatRoomId = mantraDialect.chatRoomId,
)
return try {
database.mantraDialectDao().upsert(mantraDialect)
database.marmotInnerEventDao().upsert(dialectInnerEvent)
sendMarmotInnerEvent(
localChatRoom = localChatRoom,
submitToGroup(
chatRoomId = mantraDialect.chatRoomId,
submitterPublicKey = userPublicKey,
payload = rumorOf(dialectEventTemplate, userPublicKey),
text = "Added $name as a dialect",
marmotInnerEvent = dialectInnerEvent
)
dialectInnerEvent
mantraDialect
} catch (error: Throwable) {
logger.e("Failed to add dialect \"$name\" to chat room ${localChatRoom.chatRoom.id}", error)
null
@@ -94,7 +166,7 @@ abstract class MantraDao(
userPublicKey: HexKey,
visibility: String = DEFAULT_VISIBILITY,
license: String = DEFAULT_LICENSE,
): MarmotInnerEvent? {
): MantraArtifact? {
// TODO: Verify the active user is an admin of the chat room before allowing this.
val artifactEventTemplate = ArtifactEvent.build(
name = name,
@@ -110,32 +182,19 @@ abstract class MantraDao(
userPublicKey = userPublicKey,
) ?: return null
// Persist the artifact together with an unprocessed marmot inner event
// (a rumor). Inner events with a null marmotGroupEventId are later
// picked up by the outbound pipeline and encrypted into a kind:445
// group event for the chat room.
// 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.
val artifactInnerEvent = MarmotInnerEvent(
id = mantraArtifact.id,
publicKey = mantraArtifact.publicKey,
kind = ArtifactEvent.KIND,
createdAt = mantraArtifact.createdAt,
tags = artifactEventTemplate.tags,
content = artifactEventTemplate.content,
chatRoomId = mantraArtifact.chatRoomId,
)
return try {
database.mantraArtifactDao().upsert(mantraArtifact)
database.marmotInnerEventDao().upsert(artifactInnerEvent)
sendMarmotInnerEvent(
localChatRoom = localChatRoom,
submitToGroup(
chatRoomId = mantraArtifact.chatRoomId,
submitterPublicKey = userPublicKey,
payload = rumorOf(artifactEventTemplate, userPublicKey),
text = "Added $name to artifacts",
marmotInnerEvent = artifactInnerEvent
)
// Every artifact starts with an initial version.
@@ -146,7 +205,7 @@ abstract class MantraDao(
userPublicKey = userPublicKey,
)
artifactInnerEvent
mantraArtifact
} catch (error: Throwable) {
logger.e("Failed to add artifact \"$name\" to chat room ${localChatRoom.chatRoom.id}", error)
null
@@ -159,7 +218,7 @@ abstract class MantraDao(
artifactId: HexKey,
versionLabel: String,
userPublicKey: HexKey,
): MarmotInnerEvent? {
): MantraArtifactVersion? {
return addArtifactVersionInternal(
localChatRoom = localChatRoom,
artifactId = artifactId,
@@ -173,7 +232,7 @@ abstract class MantraDao(
artifactId: HexKey,
versionLabel: String,
userPublicKey: HexKey,
): MarmotInnerEvent? {
): MantraArtifactVersion? {
// The version label is carried in the event content (see
// MantraArtifactVersion.fromArtifactVersionEvent).
val artifactVersionEventTemplate = ArtifactVersionEvent.build(
@@ -188,25 +247,16 @@ abstract class MantraDao(
userPublicKey = userPublicKey,
) ?: return null
val versionInnerEvent = MarmotInnerEvent(
id = mantraArtifactVersion.id,
publicKey = mantraArtifactVersion.publicKey,
kind = ArtifactVersionEvent.KIND,
createdAt = mantraArtifactVersion.createdAt,
tags = artifactVersionEventTemplate.tags,
content = artifactVersionEventTemplate.content,
chatRoomId = mantraArtifactVersion.chatRoomId,
)
database.mantraArtifactVersionDao().upsert(mantraArtifactVersion)
database.marmotInnerEventDao().upsert(versionInnerEvent)
sendMarmotInnerEvent(
localChatRoom = localChatRoom,
submitToGroup(
chatRoomId = mantraArtifactVersion.chatRoomId,
submitterPublicKey = userPublicKey,
payload = rumorOf(artifactVersionEventTemplate, userPublicKey),
text = "Add the ${artifactVersionEventTemplate.content} version",
marmotInnerEvent = versionInnerEvent
)
return versionInnerEvent
return mantraArtifactVersion
}
@Transaction
@@ -216,7 +266,7 @@ abstract class MantraDao(
originalText: String,
chatRoomId: String,
userPublicKey: HexKey,
): MarmotInnerEvent? {
): MantraChapter? {
// Chapters attach to an artifact version; use the latest one.
val version = database.mantraArtifactVersionDao()
@@ -244,24 +294,12 @@ abstract class MantraDao(
return try {
database.mantraChapterDao().upsert(chapter)
val chapterInnerEvent = MarmotInnerEvent(
id = chapter.id,
publicKey = chapter.publicKey,
kind = ChapterEvent.KIND,
createdAt = chapter.createdAt,
tags = chapterEventTemplate.tags,
content = chapterEventTemplate.content,
chatRoomId = chapter.chatRoomId,
)
database.marmotInnerEventDao().upsert(
chapterInnerEvent
)
sendMarmotInnerEvent(
submitToGroup(
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
submitterPublicKey = userPublicKey,
payload = rumorOf(chapterEventTemplate, userPublicKey),
text = "Added chapter to artifact", // TODO: Get artifact to use in text...
marmotInnerEvent = chapterInnerEvent
)
// Split the markdown into paragraph chunks.
@@ -279,30 +317,17 @@ abstract class MantraDao(
userPublicKey = userPublicKey,
)?.let { chunk ->
database.mantraChunkDao().upsert(chunk)
val chunkInnerEvent = MarmotInnerEvent(
id = chunk.id,
publicKey = chunk.publicKey,
kind = ChunkEvent.KIND,
createdAt = chunk.createdAt,
tags = chunkEventTemplate.tags,
content = chunkEventTemplate.content,
chatRoomId = chunk.chatRoomId,
)
database.marmotInnerEventDao().upsert(
chunkInnerEvent
)
sendMarmotInnerEvent(
submitToGroup(
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
submitterPublicKey = userPublicKey,
payload = rumorOf(chunkEventTemplate, userPublicKey),
text = "${chapter.name} added chunk $chunkIndex", // TODO: Use a portion of the actual chunked text...
marmotInnerEvent = chunkInnerEvent
)
}
}
chapterInnerEvent
chapter
} catch (error: Throwable) {
logger.e("Failed to add chapter \"$name\" to artifact $artifactId", error)
null
@@ -315,7 +340,7 @@ abstract class MantraDao(
dialectId: String,
chatRoomId: String,
userPublicKey: HexKey,
): MarmotInnerEvent? {
): MantraTranslationArtifactVersion? {
val artifact = database.mantraArtifactDao().getArtifactById(artifactId) ?: return null
val version = database.mantraArtifactVersionDao()
.getArtifactVersionsByArtifactId(artifactId)
@@ -339,21 +364,12 @@ abstract class MantraDao(
return try {
database.mantraTranslationArtifactVersionDao().upsert(translationVersion)
val translationArtifactVersionInnerEvent = MarmotInnerEvent(
id = translationVersion.id,
publicKey = translationVersion.publicKey,
kind = TranslationArtifactVersionEvent.KIND,
createdAt = translationVersion.createdAt,
tags = translationVersionTemplate.tags,
content = translationVersionTemplate.content,
chatRoomId = translationVersion.chatRoomId,
)
sendMarmotInnerEvent(
submitToGroup(
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
submitterPublicKey = userPublicKey,
payload = rumorOf(translationVersionTemplate, userPublicKey),
text = "Added ${dialect.name} translation",
marmotInnerEvent = translationArtifactVersionInnerEvent
)
// Mirror the source structure: a translation chapter per chapter and
@@ -372,21 +388,12 @@ abstract class MantraDao(
) ?: return@forEach
database.mantraTranslationChapterDao().upsert(translationChapter)
val translationChapterInnerEvent = MarmotInnerEvent(
id = translationChapter.id,
publicKey = translationChapter.publicKey,
kind = TranslationChapterEvent.KIND,
createdAt = translationChapter.createdAt,
tags = translationChapterTemplate.tags,
content = translationChapterTemplate.content,
chatRoomId = translationChapter.chatRoomId,
)
sendMarmotInnerEvent(
submitToGroup(
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
submitterPublicKey = userPublicKey,
payload = rumorOf(translationChapterTemplate, userPublicKey),
text = "Prepared ${dialect.name} translation of the chapter ${chapter.name}",
marmotInnerEvent = translationChapterInnerEvent
)
// TODO: Figure out if we seriously need the scaffolding
@@ -427,7 +434,7 @@ abstract class MantraDao(
// }
}
translationArtifactVersionInnerEvent
translationVersion
} catch (error: Throwable) {
logger.e("Failed to add translation for artifact $artifactId", error)
null
@@ -460,30 +467,25 @@ abstract class MantraDao(
return try {
// Replace any existing translation chunk for this source chunk. Its id
// is derived from the (now changed) content, so it becomes a new row —
// drop the old one (and its rumor) to keep one per source chunk.
// drop the old one (and the submission carrying it) to keep one per
// source chunk. The submission is found by what it carries, since its
// own id is the envelope's rather than the chunk's.
database.mantraTranslationChunkDao()
.getTranslationChunksByTranslationChapterId(translationChapterId)
.filter { it.chunkId == chunkId && it.id != translationChunk.id }
.forEach { stale ->
database.mantraTranslationChunkDao().deleteById(stale.id)
database.marmotInnerEventDao().deleteById(stale.id)
database.marmotInnerEventDao().deleteByPayloadEventId(stale.id)
}
database.mantraTranslationChunkDao().upsert(translationChunk)
val translationChunkInnerEvent = MarmotInnerEvent(
id = translationChunk.id,
publicKey = translationChunk.publicKey,
kind = TranslationChunkEvent.KIND,
createdAt = translationChunk.createdAt,
tags = translationChunkTemplate.tags,
content = translationChunkTemplate.content,
chatRoomId = translationChunk.chatRoomId,
)
sendMarmotInnerEvent(
submitToGroup(
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
submitterPublicKey = userPublicKey,
payload = rumorOf(translationChunkTemplate, userPublicKey),
text = "Translated chunk ${translationChunk.index}", // TODO: Use a portion of the translation and name the language
marmotInnerEvent = translationChunkInnerEvent
)
translationChunk
@@ -493,19 +495,6 @@ abstract class MantraDao(
}
}
private suspend fun sendMarmotInnerEvent(
localChatRoom: LocalChatRoom,
text: String,
marmotInnerEvent: MarmotInnerEvent
) {
sendMarmotInnerEvent(
chatRoomId = localChatRoom.chatRoom.id,
userPublicKey = localChatRoom.chatRoom.userPublicKey,
text = text,
marmotInnerEvent = marmotInnerEvent
)
}
private suspend fun sendMarmotInnerEvent(
chatRoomId: String,
userPublicKey: HexKey,

View File

@@ -15,6 +15,25 @@ interface MarmotInnerEventDao {
@Upsert
suspend fun upsert(marmotInnerEvent: MarmotInnerEvent)
/**
* A room's inner events of the given kinds, oldest first.
*
* Used to replay a protocol backlog: a session's messages are stored as they
* decrypt, but one that arrives before the proposal opening its session has
* nowhere to be filed at the time.
*/
@Query("SELECT * FROM MarmotInnerEvent WHERE chatRoomId = :chatRoomId AND kind IN (:kinds) ORDER BY createdAt ASC")
suspend fun getByChatRoomAndKinds(chatRoomId: String, kinds: List<Int>): List<MarmotInnerEvent>
@Query("DELETE FROM MarmotInnerEvent WHERE id = :id")
suspend fun deleteById(id: String)
/**
* Drop the submissions carrying [payloadEventId].
*
* A submission's id is the envelope's, not the payload's, so a superseded
* nip30303 event cannot be un-queued by its own id.
*/
@Query("DELETE FROM MarmotInnerEvent WHERE payloadEventId = :payloadEventId")
suspend fun deleteByPayloadEventId(payloadEventId: String)
}

View File

@@ -437,17 +437,13 @@ abstract class MarmotOutboundDao(
// Save commitResult... in case we need to broadcast welcomeEvent after relay acknowledgement...
database.marmotCommitResultDao().upsert(
MarmotCommitResult(
id = commitEvent.id,
isOneMemberInitialGroupCreation = isOneMemberInitialGroupCreation,
MarmotCommitResult.from(
commitEventId = commitEvent.id,
commitResult = commitResult,
chatRoomId = nostrGroupId,
commitBytes = commitResult.commitBytes,
preCommitExporterSecret = commitResult.preCommitExporterSecret,
welcomeBytes = commitResult.welcomeBytes,
framedCommitBytes = commitResult.preCommitExporterSecret,
groupInfoBytes = commitResult.groupInfoBytes,
userPublicKey = userPublicKey,
peerKeyPackageEventId = peerKeyPackage.id,
isOneMemberInitialGroupCreation = isOneMemberInitialGroupCreation,
createdAt = Instant.fromEpochSeconds(commitEvent.createdAt)
)
)

View File

@@ -31,6 +31,11 @@ import press.mantra.compose.exceptions.MarmotWelcomeEventMissingKeyPackageEventI
import press.mantra.compose.extensions.toHex
import press.mantra.compose.managers.ChillDkgRitualManager
import press.mantra.compose.nostr.dkg.DkgRitualEvents
import press.mantra.compose.nostr.frost.FrostSigningEvents
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
import press.mantra.compose.managers.FrostSigningManager
import press.mantra.compose.managers.GroupKeyStateManager
import press.mantra.compose.managers.MlsGroupCache
import press.mantra.compose.managers.MarmotInboundManager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CancellationException
@@ -43,6 +48,7 @@ import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle
import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage
import com.vitorpamplona.quartz.marmot.mls.tree.Credential
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
@@ -385,9 +391,21 @@ abstract class NostrDao(
val localChatRoom = database.chatRoomDao().findChatRoomById(chatRoomId)
if (localChatRoom != null) {
val mlsGroup = localChatRoom.chatRoom.toMlsGroup()
if (mlsGroup != null) {
// Through the cache rather than rebuilt here, so the secret
// tree's skipped-generation keys survive from one message to
// the next. Two events published in the same instant arrive in
// whatever order the relay feels like, and rebuilding between
// them loses the earlier one for good -- see MlsGroupCache.
val handled = MlsGroupCache.withGroup(
chatRoomId = chatRoomId,
storedStateHex = localChatRoom.chatRoom.mlsGroupState,
build = { localChatRoom.chatRoom.toMlsGroup() },
save = { stateHex ->
database.chatRoomDao().upsert(
localChatRoom.chatRoom.copy(mlsGroupState = stateHex)
)
}
) { mlsGroup ->
val memberPubkeys = mlsGroup.members().mapNotNull { (leafIndex, leafNode) ->
val pubkey = when (val cred = leafNode.credential) {
@@ -434,12 +452,6 @@ abstract class NostrDao(
}
}
// Save the mls chatRoom state...
database.chatRoomDao().upsert(
localChatRoom.chatRoom.copy(
mlsGroupState = mlsGroup.saveState().encodeTls().toHex()
)
)
ChatMessage.fromGroupEventResult(
database = database,
activeKeyPair = activeKeyPair,
@@ -458,11 +470,47 @@ abstract class NostrDao(
chatMessage
)
}
// A FROST signing message for this group. Driven from
// here rather than from ChatMessage because the
// manager needs the room to publish its own replies
// into, and because it writes its transcript lines
// itself. The manager is idempotent, so a redelivered
// message re-runs a step it has already taken.
if (groupEventResult is GroupEventResult.ApplicationMessage) {
Event.fromJsonOrNull(groupEventResult.innerEventJson)?.let { innerEvent ->
when {
FrostSigningEvents.isFrostSigningKind(innerEvent.kind) ->
FrostSigningManager.processSigningPayload(
database = database,
localChatRoom = localChatRoom,
innerEvent = innerEvent,
userPublicKey = activeKeyPair.pubKey.toHex()
)
// What key this room signs with. Filed
// rather than acted on, and only after
// the room rederives from the key it
// names -- the manager drops anything
// that does not, whoever sent it.
GroupKeyStateEvent.isGroupKeyStateKind(innerEvent.kind) ->
GroupKeyStateManager.record(
database = database,
chatRoomId = localChatRoom.chatRoom.id,
innerEvent = innerEvent
)
}
}
}
}
} else {
throw MarmotNotMemberOfChatGroupException("We are not a member of the chat room ${localChatRoom.chatRoom.id}")
}
} else {
}
// Null means the room has no usable group state, which is what
// a failed toMlsGroup() meant before the cache existed.
if (handled == null) {
throw MarmotMissingNostrGroupDataExtension("Couldn't find chatRoom for $nostrEvent")
}
} else {
@@ -508,6 +556,15 @@ abstract class NostrDao(
giftWrapMessage
)
if (!giftWrapMessage.isAddressedTo(activeKeyPair)) {
// Undecryptable by design rather than by failure, so keep the event and
// the wrap we just stored and stop here. Throwing would roll the whole
// transaction back and lose both.
logger.d("GiftWrap ${nostrEvent.id} is addressed to ${giftWrapMessage.receiverPublicKey}, nothing to index")
return@let
}
giftWrapMessage.decryptGiftWrapSeal(
activeKeyPair
).let { giftWrapSeal ->
@@ -646,41 +703,11 @@ abstract class NostrDao(
}
}
if (chatMessageRelayListEvent != null) {
// Sync messages from this relay that were sent by us
val synchronizationFilter =
SynchronizationFilter(
kinds = arrayOf(
GiftWrapEvent.KIND,
),
authors = arrayOf(
userPublicKey
),
tags = mapOf(
Pair(
"p",
listOf(participant.participantPublicKey)
)
),
limit = 50
)
database.negentropySynchronizeRequestDao()
.insert(
chatMessageRelayListEvent.relays()
.map { normalizedRelayUrl ->
NegentropySynchronizeRequest(
id = NegentropySynchronizeRequest.computeId(
relayURL = normalizedRelayUrl.url,
synchronizationFilter = synchronizationFilter
),
purpose = "sent-messages",
synchronizationFilter = synchronizationFilter,
relayURL = normalizedRelayUrl.url,
level = 0
)
}
)
} else {
// Nothing to sync when we do have the relay list: a gift
// wrap is authored by a throwaway key, so authors=us matched
// nothing and this request was always empty. Our own inbox is
// synced by p-tag on the chat room list instead.
if (chatMessageRelayListEvent == null) {
logger.w("We don't have a chatMessageRelayListEvent for the pubkey $publicKey")
// Sync ChatMessageRelayListEvent publicKey...
@@ -882,35 +909,9 @@ abstract class NostrDao(
}
}
if (chatMessageRelayListEvent != null) {
// Sync messages from this relay that were sent by us
val synchronizationFilter = SynchronizationFilter(
kinds = arrayOf(
GiftWrapEvent.KIND,
),
authors = arrayOf(
userPublicKey
),
tags = mapOf(
Pair("p", listOf(participant.participantPublicKey))
),
limit = 50
)
database.negentropySynchronizeRequestDao().insert(
chatMessageRelayListEvent.relays().map { normalizedRelayUrl ->
NegentropySynchronizeRequest(
id = NegentropySynchronizeRequest.computeId(
relayURL = normalizedRelayUrl.url,
synchronizationFilter = synchronizationFilter
),
purpose = "sent-messages",
synchronizationFilter = synchronizationFilter,
relayURL = normalizedRelayUrl.url,
level = 0
)
}
)
} else {
// See the identical block above: authors=us never matches a
// gift wrap, so only the missing-relay-list case has work to do.
if (chatMessageRelayListEvent == null) {
logger.w("We don't have a chatMessageRelayListEvent for the pubkey $publicKey")
// Sync ChatMessageRelayListEvent publicKey...

View File

@@ -17,11 +17,14 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import press.mantra.compose.nostr.frost.FrostSigningEvents
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
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
import press.mantra.compose.nostr.nip30303.DialectEvent
import press.mantra.compose.nostr.nip30303.SubmissionEvent
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionContributorListEvent
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
@@ -98,6 +101,8 @@ data class ChatMessage(
): TimestampedEntity, LocalStoreEntity, UserViewableEntity, SoftDeletableEntity {
companion object {
private val logger = Logger.withTag("ChatMessage")
/**
* A one-to-one message inside a group, as a line in the group's chat.
*
@@ -204,6 +209,65 @@ data class ChatMessage(
* A finished ceremony is the exception, and has no actor: the group ends up
* with a key, nobody hands it to them.
*/
/**
* A FROST signing session, as lines in the group's chat.
*
* The same shape as the ceremony's, and for the same reason: signing with
* the group's key is otherwise a black box, and a session stalled on one
* member gives no way to see whose door to knock on.
*/
const val TYPE_FROST_STARTED = "frostStarted"
const val TYPE_FROST_NONCE = "frostNonce"
const val TYPE_FROST_SIGNER_SET = "frostSignerSet"
const val TYPE_FROST_PARTIAL_SIGNATURE = "frostPartialSignature"
const val TYPE_FROST_SIGNATURE = "frostSignature"
const val TYPE_FROST_COMPLETE = "frostComplete"
const val TYPE_FROST_FAILED = "frostFailed"
/** Addressed to the reader rather than said by anyone -- see [DKG_REQUEST_TYPES]. */
const val TYPE_FROST_APPROVAL_NEEDED = "frostApprovalNeeded"
/**
* Answering the request publishes this device's nonce, not its signature:
* approving is agreeing to take part, and the coordinator may then pick a
* quorum that does not include this member. Keying the answer on the
* partial signature would leave a member who agreed, and was not needed,
* looking like they never replied.
*/
val FROST_REQUEST_FULFILMENTS = mapOf(
TYPE_FROST_APPROVAL_NEEDED to TYPE_FROST_NONCE,
)
/** The signing lines that ask rather than report. */
val FROST_REQUEST_TYPES = setOf(TYPE_FROST_APPROVAL_NEEDED)
/** Every signing line, for rendering them as system lines rather than bubbles. */
val FROST_TYPES = setOf(
TYPE_FROST_STARTED,
TYPE_FROST_NONCE,
TYPE_FROST_SIGNER_SET,
TYPE_FROST_PARTIAL_SIGNATURE,
TYPE_FROST_SIGNATURE,
TYPE_FROST_COMPLETE,
TYPE_FROST_FAILED,
TYPE_FROST_APPROVAL_NEEDED,
)
/**
* The signing lines somebody did, as opposed to ones that simply happened.
* Their content is written as a predicate for the actor's name to be read
* in front of. A finished signature has no actor: the group ends up with
* one, nobody hands it to them.
*/
val FROST_AUTHORED_TYPES = setOf(
TYPE_FROST_STARTED,
TYPE_FROST_NONCE,
TYPE_FROST_SIGNER_SET,
TYPE_FROST_PARTIAL_SIGNATURE,
TYPE_FROST_SIGNATURE,
TYPE_FROST_FAILED,
)
val DKG_AUTHORED_TYPES = setOf(
TYPE_DKG_STARTED,
TYPE_DKG_HOST_KEY,
@@ -214,8 +278,6 @@ data class ChatMessage(
TYPE_DKG_FAILED,
)
private val logger = Logger.withTag("ChatMessage")
/**
* Files one direct message, from whichever side of it this device is on.
*
@@ -346,6 +408,25 @@ data class ChatMessage(
val event = Event.fromJsonOrNull(groupEventResult.innerEventJson)
?: throw MarmotUnprocessableInnerEventException("Can't process ${groupEventResult.innerEventJson}")
// A submission is an envelope: the nip30303 event it delivers
// is in its content, written by whoever wrote it. Everything
// else on the wire is the nip30303 event itself. Either way the
// row on disk is the outer event -- that is what the group sent
// and what the chat line is attributed to.
val submission = if (event.kind == SubmissionEvent.KIND) {
SubmissionEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig,
)
} else {
null
}
val payload = submission?.payload()
database.marmotInnerEventDao().upsert(
MarmotInnerEvent(
id = event.id,
@@ -355,306 +436,51 @@ data class ChatMessage(
content = event.content,
chatRoomId = groupEventResult.groupId,
kind = event.kind,
payloadEventId = payload?.id,
createdAt = Instant.fromEpochSeconds(event.createdAt)
)
)
when (event.kind) {
ChatEvent.KIND -> {
ChatMessage(
giftWrapPayloadId = null,
messageType = "message",
marmotGroupEventId = groupEvent.id,
marmotInnerEventId = event.id,
senderPublicKey = event.pubKey,
isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(),
chatRoomId = groupEventResult.groupId,
createdAt = Instant.fromEpochSeconds(event.createdAt),
content = event.content, // TODO: Figure out what to do here...
)
}
ArtifactEvent.KIND -> {
MantraArtifact.fromArtifactEvent(
artifactEvent = ArtifactEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig,
),
chatRoomId = groupEventResult.groupId,
)?.let { mantraArtifact ->
database.mantraArtifactDao().upsert(
mantraArtifact.copy(
marmotGroupEventId = groupEvent.id,
)
)
ChatMessage(
giftWrapPayloadId = null,
messageType = "artifact",
marmotGroupEventId = groupEvent.id,
marmotInnerEventId = event.id,
senderPublicKey = event.pubKey,
isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(),
chatRoomId = groupEventResult.groupId,
createdAt = Instant.fromEpochSeconds(event.createdAt),
content = "Added ${mantraArtifact.name} to artifacts"
)
}
}
ArtifactVersionEvent.KIND -> {
MantraArtifactVersion.fromArtifactVersionEvent(
artifactVersionEvent = ArtifactVersionEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig
),
chatRoomId = groupEventResult.groupId,
)?.let { mantraArtifactVersion ->
database.mantraArtifactVersionDao().upsert(
mantraArtifactVersion.copy(
marmotGroupEventId = groupEvent.id,
)
)
// A direct message is not a nip30303 payload to apply. It is
// gift wrapped for one member, and what this device does with it
// depends only on whether its key opens it.
if (event.kind == GiftWrapEvent.KIND) {
return directMessage(
database = database,
activeKeyPair = activeKeyPair,
groupEvent = groupEvent,
chatRoomId = groupEventResult.groupId,
wrap = event,
senderIdentity = senderIdentity
)
}
ChatMessage(
giftWrapPayloadId = null,
messageType = "artifactVersion",
marmotGroupEventId = groupEvent.id,
marmotInnerEventId = event.id,
senderPublicKey = event.pubKey,
isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(),
chatRoomId = groupEventResult.groupId,
createdAt = Instant.fromEpochSeconds(event.createdAt),
content = "Added ${mantraArtifactVersion.versionLabel} to artifact versions" // TODO: Use artifact name...
)
}
}
ChapterEvent.KIND -> {
MantraChapter.fromChapterEvent(
chapterEvent = ChapterEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig
),
chatRoomId = groupEventResult.groupId,
)?.let { mantraChapter ->
database.mantraChapterDao().upsert(
mantraChapter.copy(
marmotGroupEventId = groupEvent.id,
)
)
ChatMessage(
giftWrapPayloadId = null,
messageType = "chapter",
marmotGroupEventId = groupEvent.id,
marmotInnerEventId = event.id,
senderPublicKey = event.pubKey,
isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(),
chatRoomId = groupEventResult.groupId,
createdAt = Instant.fromEpochSeconds(event.createdAt),
content = "Added ${mantraChapter.name} to chapters" // TODO: Use artifact name...
)
}
}
ChunkEvent.KIND -> {
MantraChunk.fromChunkEvent(
chunkEvent = ChunkEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig
),
chatRoomId = groupEventResult.groupId,
)?.let { mantraChunk ->
database.mantraChunkDao().upsert(
mantraChunk.copy(
marmotGroupEventId = groupEvent.id,
)
)
// TODO: Chunks might be too noisy to show in chat...
}
null
}
DialectEvent.KIND -> {
MantraDialect.fromDialectEvent(
dialectEvent = DialectEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig
),
chatRoomId = groupEventResult.groupId,
)?.let { mantraDialect ->
database.mantraDialectDao().upsert(
mantraDialect.copy(
marmotGroupEventId = groupEvent.id,
)
)
ChatMessage(
giftWrapPayloadId = null,
messageType = "dialect",
marmotGroupEventId = groupEvent.id,
marmotInnerEventId = event.id,
senderPublicKey = event.pubKey,
isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(),
chatRoomId = groupEventResult.groupId,
createdAt = Instant.fromEpochSeconds(event.createdAt),
content = "Added ${mantraDialect.name} to dialects" // TODO: Use artifact name...
)
}
}
TranslationArtifactVersionEvent.KIND -> {
MantraTranslationArtifactVersion.fromTranslationArtifactVersionEvent(
translationArtifactVersionEvent = TranslationArtifactVersionEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig
),
chatRoomId = groupEventResult.groupId,
)?.let { mantraTranslationArtifactVersion ->
database.mantraTranslationArtifactVersionDao().upsert(
mantraTranslationArtifactVersion.copy(
marmotGroupEventId = groupEvent.id,
)
)
ChatMessage(
giftWrapPayloadId = null,
messageType = "translationArtifactVersion",
marmotGroupEventId = groupEvent.id,
marmotInnerEventId = event.id,
senderPublicKey = event.pubKey,
isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(),
chatRoomId = groupEventResult.groupId,
createdAt = Instant.fromEpochSeconds(event.createdAt),
content = "Added ${mantraTranslationArtifactVersion.name} to translation artifact versions" // TODO: Use artifact name...
)
}
}
TranslationArtifactVersionContributorListEvent.KIND -> {
// TODO: Consume contributors...
null
}
TranslationChapterEvent.KIND -> {
MantraTranslationChapter.fromTranslationChapterEvent(
translationChapterEvent = TranslationChapterEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig
),
chatRoomId = groupEventResult.groupId,
)?.let { mantraTranslationChapter ->
database.mantraTranslationChapterDao().upsert(
mantraTranslationChapter.copy(
marmotGroupEventId = groupEvent.id,
)
)
// TODO: translation chapter might be too noisy for chat updates
}
null
}
TranslationChunkEvent.KIND -> {
MantraTranslationChunk.fromTranslationChunkEvent(
translationChunkEvent = TranslationChunkEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig
),
chatRoomId = groupEventResult.groupId,
)?.let { mantraTranslationChunk ->
database.mantraTranslationChunkDao().upsert(
mantraTranslationChunk.copy(
marmotGroupEventId = groupEvent.id,
)
)
}
null
}
TranslationContributorListEvent.KIND -> {
// TODO: Consume contributors...
null
}
TranslationEvent.KIND -> {
MantraTranslation.fromTranslationEvent(
translationEvent = TranslationEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig
),
chatRoomId = groupEventResult.groupId,
)?.let { mantraTranslation ->
database.mantraTranslationDao().upsert(
mantraTranslation.copy(
marmotGroupEventId = groupEvent.id,
)
)
ChatMessage(
giftWrapPayloadId = null,
messageType = "translation",
marmotGroupEventId = groupEvent.id,
marmotInnerEventId = event.id,
senderPublicKey = event.pubKey,
isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(),
chatRoomId = groupEventResult.groupId,
createdAt = Instant.fromEpochSeconds(event.createdAt),
content = "Added ${mantraTranslation.text} to translations" // TODO: Reference original text
)
}
}
GiftWrapEvent.KIND -> {
directMessage(
database = database,
activeKeyPair = activeKeyPair,
groupEvent = groupEvent,
chatRoomId = groupEventResult.groupId,
wrap = event,
senderIdentity = senderIdentity
)
}
else -> {
ChatMessage(
giftWrapPayloadId = null,
messageType = "unsupported",
marmotGroupEventId = groupEvent.id,
marmotInnerEventId = event.id,
senderPublicKey = groupEvent.pubKey,
isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(),
chatRoomId = groupEventResult.groupId,
createdAt = Instant.fromEpochSeconds(groupEvent.createdAt),
content = groupEventResult.innerEventJson, // TODO: Figure out what to do here...
)
}
// A submission whose payload will not parse, or which carries
// another submission, is kept but not applied: there is nothing
// here we can turn into a row.
if (submission != null && (payload == null || payload.kind == SubmissionEvent.KIND)) {
ChatMessage(
giftWrapPayloadId = null,
messageType = "unsupported",
marmotGroupEventId = groupEvent.id,
marmotInnerEventId = event.id,
senderPublicKey = event.pubKey,
isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(),
chatRoomId = groupEventResult.groupId,
createdAt = Instant.fromEpochSeconds(event.createdAt),
content = event.content,
)
} else {
applyInnerEvent(
database = database,
groupId = groupEventResult.groupId,
event = payload ?: event,
marmotGroupEventId = groupEvent.id,
marmotInnerEventId = event.id,
senderPublicKey = event.pubKey,
isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(),
createdAt = Instant.fromEpochSeconds(event.createdAt),
)
}
}
is GroupEventResult.CommitPending -> {
@@ -738,5 +564,330 @@ data class ChatMessage(
}
}
}
/**
* Apply one nip30303 [event] the group delivered, and describe it for
* the transcript.
*
* [event] is what is being applied; the other parameters are how it
* arrived. They come apart for submissions: the payload is written by
* whoever wrote it -- possibly nobody in this group -- while
* [marmotInnerEventId], [senderPublicKey] and [createdAt] all belong to
* the envelope a member actually sent. Entity rows take their author
* from [event], so the chat line says who added it and the row says who
* wrote it.
*/
internal suspend fun applyInnerEvent(
database: MantraDatabase,
groupId: String,
event: Event,
marmotGroupEventId: HexKey?,
marmotInnerEventId: HexKey?,
senderPublicKey: HexKey,
isUserMessage: Boolean,
createdAt: Instant,
): ChatMessage? {
return when (event.kind) {
ChatEvent.KIND -> {
ChatMessage(
giftWrapPayloadId = null,
messageType = "message",
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
content = event.content, // TODO: Figure out what to do here...
)
}
ArtifactEvent.KIND -> {
MantraArtifact.fromArtifactEvent(
artifactEvent = ArtifactEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig,
),
chatRoomId = groupId,
)?.let { mantraArtifact ->
database.mantraArtifactDao().upsert(
mantraArtifact.copy(
marmotGroupEventId = marmotGroupEventId,
)
)
ChatMessage(
giftWrapPayloadId = null,
messageType = "artifact",
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
content = "Added ${mantraArtifact.name} to artifacts"
)
}
}
ArtifactVersionEvent.KIND -> {
MantraArtifactVersion.fromArtifactVersionEvent(
artifactVersionEvent = ArtifactVersionEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig
),
chatRoomId = groupId,
)?.let { mantraArtifactVersion ->
database.mantraArtifactVersionDao().upsert(
mantraArtifactVersion.copy(
marmotGroupEventId = marmotGroupEventId,
)
)
ChatMessage(
giftWrapPayloadId = null,
messageType = "artifactVersion",
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
content = "Added ${mantraArtifactVersion.versionLabel} to artifact versions" // TODO: Use artifact name...
)
}
}
ChapterEvent.KIND -> {
MantraChapter.fromChapterEvent(
chapterEvent = ChapterEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig
),
chatRoomId = groupId,
)?.let { mantraChapter ->
database.mantraChapterDao().upsert(
mantraChapter.copy(
marmotGroupEventId = marmotGroupEventId,
)
)
ChatMessage(
giftWrapPayloadId = null,
messageType = "chapter",
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
content = "Added ${mantraChapter.name} to chapters" // TODO: Use artifact name...
)
}
}
ChunkEvent.KIND -> {
MantraChunk.fromChunkEvent(
chunkEvent = ChunkEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig
),
chatRoomId = groupId,
)?.let { mantraChunk ->
database.mantraChunkDao().upsert(
mantraChunk.copy(
marmotGroupEventId = marmotGroupEventId,
)
)
// TODO: Chunks might be too noisy to show in chat...
}
null
}
DialectEvent.KIND -> {
MantraDialect.fromDialectEvent(
dialectEvent = DialectEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig
),
chatRoomId = groupId,
)?.let { mantraDialect ->
database.mantraDialectDao().upsert(
mantraDialect.copy(
marmotGroupEventId = marmotGroupEventId,
)
)
ChatMessage(
giftWrapPayloadId = null,
messageType = "dialect",
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
content = "Added ${mantraDialect.name} to dialects" // TODO: Use artifact name...
)
}
}
TranslationArtifactVersionEvent.KIND -> {
MantraTranslationArtifactVersion.fromTranslationArtifactVersionEvent(
translationArtifactVersionEvent = TranslationArtifactVersionEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig
),
chatRoomId = groupId,
)?.let { mantraTranslationArtifactVersion ->
database.mantraTranslationArtifactVersionDao().upsert(
mantraTranslationArtifactVersion.copy(
marmotGroupEventId = marmotGroupEventId,
)
)
ChatMessage(
giftWrapPayloadId = null,
messageType = "translationArtifactVersion",
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
content = "Added ${mantraTranslationArtifactVersion.name} to translation artifact versions" // TODO: Use artifact name...
)
}
}
TranslationArtifactVersionContributorListEvent.KIND -> {
// TODO: Consume contributors...
null
}
TranslationChapterEvent.KIND -> {
MantraTranslationChapter.fromTranslationChapterEvent(
translationChapterEvent = TranslationChapterEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig
),
chatRoomId = groupId,
)?.let { mantraTranslationChapter ->
database.mantraTranslationChapterDao().upsert(
mantraTranslationChapter.copy(
marmotGroupEventId = marmotGroupEventId,
)
)
// TODO: translation chapter might be too noisy for chat updates
}
null
}
TranslationChunkEvent.KIND -> {
MantraTranslationChunk.fromTranslationChunkEvent(
translationChunkEvent = TranslationChunkEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig
),
chatRoomId = groupId,
)?.let { mantraTranslationChunk ->
database.mantraTranslationChunkDao().upsert(
mantraTranslationChunk.copy(
marmotGroupEventId = marmotGroupEventId,
)
)
}
null
}
TranslationContributorListEvent.KIND -> {
// TODO: Consume contributors...
null
}
TranslationEvent.KIND -> {
MantraTranslation.fromTranslationEvent(
translationEvent = TranslationEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig
),
chatRoomId = groupId,
)?.let { mantraTranslation ->
database.mantraTranslationDao().upsert(
mantraTranslation.copy(
marmotGroupEventId = marmotGroupEventId,
)
)
ChatMessage(
giftWrapPayloadId = null,
messageType = "translation",
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
content = "Added ${mantraTranslation.text} to translations" // TODO: Reference original text
)
}
}
// A signing session's own protocol messages. FrostSigningManager
// applies them and writes its own transcript lines naming who did
// what, so an "unsupported" row here would be a second, worse
// account of the same thing.
in FrostSigningEvents.ALL -> null
// The room saying what key it signs with. Standing state rather
// than something that happened, and the room's own id already
// says it to anyone who can derive -- so there is nothing here a
// reader of the transcript needs told. Falling through to
// "unsupported" would put the raw announcement in the chat.
GroupKeyStateEvent.KIND -> null
else -> {
ChatMessage(
giftWrapPayloadId = null,
messageType = "unsupported",
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
content = event.toJson(),
)
}
}
}
}
}

View File

@@ -9,6 +9,9 @@ import press.mantra.compose.database.model.traits.TimestampedEntity
import press.mantra.compose.database.model.types.DkgApprovalStep
import press.mantra.compose.database.model.types.DkgRitualStage
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import fr.acinq.bitcoin.ByteVector
import fr.acinq.bitcoin.PublicKey
import kotlin.time.Clock
import kotlin.time.Instant
@@ -82,6 +85,17 @@ data class DkgSession(
/** Result, secret: this device's FROST secret share, hex. */
val secretShare: HexKey? = null,
/**
* Result: every participant's public share, comma-separated hex, in
* participant order.
*
* Not secret, and not needed to finish the ceremony -- kept because signing
* is what the key is for and FROST validates each signer's secret share
* against its public one. Null on a ceremony that completed before this
* column existed; signing still works there, without that check.
*/
val publicShares: String? = null,
/** Result: recovery data, to be backed up alongside the host key. */
val recoveryData: HexKey? = null,
@@ -125,4 +139,11 @@ data class DkgSession(
override val savedAt: Instant = createdAt,
): TimestampedEntity, LocalStoreEntity {
fun isCoordinator(): Boolean = coordinatorPublicKey == userPublicKey
/** The participants' public shares in participant order, or null if unrecorded. */
fun publicShareList(): List<PublicKey>? = publicShares
?.split(",")
?.mapNotNull { hex -> hex.trim().takeIf { it.isNotEmpty() } }
?.map { PublicKey(ByteVector(it.hexToByteArray())) }
?.takeIf { it.isNotEmpty() }
}

View File

@@ -0,0 +1,52 @@
package press.mantra.compose.database.model
import androidx.room3.Entity
import androidx.room3.ForeignKey
import androidx.room3.Index
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import kotlin.time.Clock
import kotlin.time.Instant
/**
* One signing message from one member, keyed so a duplicate delivery overwrites
* rather than accumulates — relays redeliver, and feeding the same nonce in
* twice would make the coordinator's signer set the wrong length.
*
* Covers the nonce and the partial signature; the coordinator's own broadcasts
* live on [FrostSigningSession] because there is only ever one of each.
*
* Keyed on `(sessionId, signerPublicKey, kind)`, which also means a member
* cannot replace their own nonce once the coordinator has aggregated it — a
* second nonce from the same signer overwrites the first, and the aggregate
* built from it simply stops matching. The session's own write-once rule on the
* aggregate is what makes that a stalled session rather than a leaked share.
*/
@Entity(
primaryKeys = ["sessionId", "signerPublicKey", "kind"],
foreignKeys = [
ForeignKey(
entity = FrostSigningSession::class,
parentColumns = ["id"],
childColumns = ["sessionId"],
onDelete = ForeignKey.CASCADE,
)
],
indices = [
Index("sessionId"),
],
)
data class FrostSignerMessage(
val sessionId: String,
/** The member's nostr public key — who sent it. */
val signerPublicKey: HexKey,
/** One of the `FrostSigningEvents` kinds. */
val kind: Kind,
/** Hex of the protocol bytes: a public nonce, or a partial signature. */
val payload: HexKey,
val createdAt: Instant = Clock.System.now(),
)

View File

@@ -0,0 +1,139 @@
package press.mantra.compose.database.model
import androidx.room3.Entity
import androidx.room3.ForeignKey
import androidx.room3.Index
import androidx.room3.PrimaryKey
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlin.time.Clock
import kotlin.time.Instant
import press.mantra.compose.database.model.traits.LocalStoreEntity
import press.mantra.compose.database.model.traits.TimestampedEntity
import press.mantra.compose.database.model.types.FrostSigningStage
/**
* One FROST signing session, as this device sees it.
*
* Like [DkgSession] this stores *inputs* rather than protocol state, so a
* session survives the app being killed halfway through: every step is
* recomputed from what is on the row. Unlike a DKG that choice is not merely
* convenient here, it is forced -- `fr.acinq.bitcoin.crypto.frost.SecretNonce`
* cannot be serialised and refuses to be used twice, by design.
*
* ### The nonce, and why one session means one message
*
* [nonceRandom] is secret, and regenerating this device's nonce from it is safe
* for exactly one reason: a session signs one message and can never be made to
* sign another. `SecretNonce.generate` mixes the message in, so the same
* randomness under a different message would be a different nonce -- but the
* same randomness under the same message with two *different* aggregated nonces
* would produce two partial signatures over one secret nonce, which is how a
* secret share is extracted.
*
* Two rules keep that impossible, and both are load-bearing:
*
* - [eventId] is written when the session is created and a proposal that
* disagrees with it is rejected rather than applied.
* - [aggregatedNonce] and [signerIds] are written once. A second, different
* signer set for the same session is ignored, not honoured.
*/
@Entity(
foreignKeys = [
ForeignKey(
entity = ChatRoom::class,
parentColumns = ["id"],
childColumns = ["chatRoomId"],
onDelete = ForeignKey.CASCADE,
)
],
indices = [
Index("chatRoomId"),
Index("dkgSessionId"),
],
)
data class FrostSigningSession(
/** Minted by the proposer, carried on every message as `frost_session`. */
@PrimaryKey
val id: String,
val chatRoomId: String,
/** The member who proposed the signature, who also aggregates for it. */
val coordinatorPublicKey: HexKey,
/** Whose device this row belongs to, for multi-account support. */
val userPublicKey: HexKey,
/** The ceremony whose key this signs with — a group may hold more than one. */
val dkgSessionId: String,
/** The `t` of the t-of-n: how many partial signatures make a signature. */
val threshold: Int,
/** The `n` the key was generated for. FROST needs it to place signer ids. */
val participantCount: Int,
/** This device's FROST id: its index in the ceremony's participant order. */
val signerId: Int,
val stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES,
/**
* The unsigned event, as JSON. Kept whole so a member can be shown what
* they are being asked to sign rather than a hash of it.
*/
val unsignedEventJson: String,
/**
* The event id, which is the 32 bytes actually signed.
*
* Recomputed from the event's own fields on arrival, never taken from the
* proposal. It pins the session to one message -- see the class note on why
* that is what makes reusing [nonceRandom] safe.
*/
val eventId: HexKey,
/** Secret. 32 bytes of fresh randomness, the seed for this device's nonce. */
val nonceRandom: HexKey,
/** The coordinator's `AggregatedNonce` once it arrives, hex. Written once. */
val aggregatedNonce: HexKey? = null,
/** The chosen signers' FROST ids in aggregation order, comma separated. Written once. */
val signerIds: String? = null,
/** Result: the finished 64-byte BIP-340 signature over [eventId], hex. */
val signature: HexKey? = null,
val failureReason: String? = null,
/**
* When this device's owner agreed to sign, and with it to everything the
* session does on their behalf. Null until they do, and nothing of theirs
* goes out before it is set.
*
* One gate rather than the DKG's three. What a signer is consenting to is
* the event, and the event is fixed before they are asked: the second round
* puts no new question to them, so asking again would be asking the same
* question twice about a decision already made.
*/
val signApprovedAt: Instant? = null,
/** Whether the chat line asking for that approval has been written. */
val approvalRequestedAt: Instant? = null,
override val createdAt: Instant = Clock.System.now(),
override val updatedAt: Instant = createdAt,
override val savedAt: Instant = createdAt,
): TimestampedEntity, LocalStoreEntity {
fun isCoordinator(): Boolean = coordinatorPublicKey == userPublicKey
/** The chosen signers, or null while the coordinator has yet to choose. */
fun signerIdList(): List<Int>? = signerIds
?.split(",")
?.mapNotNull { it.trim().toIntOrNull() }
?.takeIf { it.isNotEmpty() }
/** Whether this device was picked to sign. A t-of-n key does not need everyone. */
fun isSigner(): Boolean = signerIdList()?.contains(signerId) ?: false
}

View File

@@ -7,13 +7,11 @@ import androidx.room3.PrimaryKey
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip44Encryption.Nip44
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import kotlin.time.Clock
import kotlin.time.Instant
@@ -83,6 +81,18 @@ data class GiftWrapMessage(
@Ignore
private val logger = Logger.withTag("GiftWrapMessage")
/**
* Whether this gift wrap is addressed to [keyPair], i.e. whether we hold the
* private key that can unwrap it.
*
* NIP-59 encrypts the wrap to its recipient using an ephemeral key that
* [GiftWrapEvent.create] throws away, so a wrap addressed to anyone else can
* never be decrypted by us, not even one we sent ourselves.
*/
fun isAddressedTo(
keyPair: KeyPair
): Boolean = receiverPublicKey.equals(keyPair.pubKey.toHexKey(), ignoreCase = true)
suspend fun decryptGiftWrapSeal(
keyPair: KeyPair
): GiftWrapSeal? {
@@ -105,19 +115,12 @@ data class GiftWrapMessage(
giftWrapEvent.recipientPubKey()?.let { recipientPublicKey ->
logger.d("Recipient PublicKey: $recipientPublicKey")
if (keyPair.pubKey.toHexKey() != recipientPublicKey) {
logger.e("We are unwrapping a message we may have sent from ${keyPair.pubKey.toHexKey()}")
keyPair.privKey?.let { privateKey ->
val sealJSON = Nip44.decrypt(
giftWrapEvent.content,
privateKey = privateKey,
pubKey = giftWrapEvent.pubKey.hexToByteArray()
)
logger.d("SealJSON: $sealJSON")
null
}
if (!recipientPublicKey.equals(keyPair.pubKey.toHexKey(), ignoreCase = true)) {
// Not ours to open, and no key we hold ever will be: the wrap is
// encrypted to the recipient with a one-off key that
// GiftWrapEvent.create() discards, so not even the sender can
// unwrap their own gift wrap.
logger.d("GiftWrap $id is addressed to $recipientPublicKey, not to us")
} else {
val nostrSigner = NostrSignerInternal(
keyPair = KeyPair(privKey = keyPair.privKey)

View File

@@ -0,0 +1,106 @@
package press.mantra.compose.database.model
import androidx.room3.Entity
import androidx.room3.ForeignKey
import androidx.room3.Index
import androidx.room3.PrimaryKey
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlin.time.Clock
import kotlin.time.Instant
import press.mantra.compose.database.model.traits.LocalStoreEntity
import press.mantra.compose.database.model.traits.TimestampedEntity
import press.mantra.compose.managers.SharedKeyDerivation
/**
* Which shared key a room signs with, as this device has been told.
*
* Written from a `GroupKeyStateEvent` -- the room's own announcement of the
* ceremony behind it -- and read when a signing request arrives, to pick the
* secret share out of the right [DkgSession]. A device that took part in more
* than one ceremony holds more than one share, and they are not
* interchangeable: a partial signature made with the wrong one cannot
* aggregate.
*
* ### Why this is a row and not a rederivation
*
* `FrostSigningManager.completedKey` found the key by walking every ceremony
* this device holds a share for and rederiving each one's room id until one
* matched. That works, and it stays as the fallback for rooms made before this
* table existed, but it can only find rooms derived at the *default* path --
* the one path the constant names. A room derived anywhere else was invisible
* to it. [derivationPath] is what fixes that, which is also the reason the path
* is stored rather than assumed.
*
* ### Nothing secret lives here
*
* [thresholdPublicKey] is the key signatures verify against, not the secret
* behind it, and [dkgSessionId] is a pointer. The share itself never leaves
* `DkgSession.secretShare` on the device that generated it.
*
* One row per room: a room is derived from one key, and a group that re-runs
* its ceremony derives a different room rather than re-keying this one.
*/
@Entity(
foreignKeys = [
ForeignKey(
entity = ChatRoom::class,
parentColumns = ["id"],
childColumns = ["chatRoomId"],
onDelete = ForeignKey.CASCADE,
)
],
indices = [
Index("dkgSessionId"),
],
)
data class GroupKeyState(
/** The Marmot room this is the key state for. Its id is the derived key. */
@PrimaryKey
val chatRoomId: String,
/**
* The ceremony that made the key, and so the row holding this device's
* share of it.
*
* Not a foreign key on purpose. A member can be in the room without
* holding a share -- they were added after the ceremony, or reinstalled --
* and the state is still worth keeping: it says what the room signs with,
* which is what tells them they cannot.
*/
val dkgSessionId: String,
/** The group's ChillDKG threshold public key, 33-byte compressed hex. */
val thresholdPublicKey: HexKey,
/** The path [chatRoomId] was derived at, `m/9420/0/0` style. */
val derivationPath: String,
/** Who announced it. Kept for the transcript; the derivation is what vouches for it. */
val announcedBy: HexKey,
/** The announcement's own timestamp, so the newest state per room wins. */
val announcedAt: Instant,
override val createdAt: Instant = Clock.System.now(),
override val updatedAt: Instant = createdAt,
override val savedAt: Instant = createdAt,
): TimestampedEntity, LocalStoreEntity {
/** [derivationPath] as indices, or null if it is not a walkable path. */
fun pathIndices(): List<Long>? = SharedKeyDerivation.parsePathString(derivationPath)
/**
* Whether this state actually describes the room it claims to.
*
* The room's id is the threshold key derived at the path, so this is the
* whole of the trust model: a state that does not rederive its own room was
* announced by somebody pointing the room at a key it was not made from.
* Checked before the row is written and cheap enough to check again.
*/
fun verifies(): Boolean {
val path = pathIndices() ?: return false
return runCatching {
SharedKeyDerivation.marmotGroupId(thresholdPublicKey, path) == chatRoomId
}.getOrDefault(false)
}
}

View File

@@ -8,6 +8,7 @@ import press.mantra.compose.database.model.traits.SoftDeletableEntity
import press.mantra.compose.database.model.traits.TimestampedEntity
import press.mantra.compose.database.model.traits.UserViewableEntity
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.marmot.mls.messages.CommitResult
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlin.time.Clock
import kotlin.time.Instant
@@ -72,6 +73,40 @@ data class MarmotCommitResult( // TODO: Rename this to GiftWrapPayload...
companion object {
const val TAG = "MarmotCommitResult"
/**
* The persisted record of a commit, built from the [CommitResult] that produced it.
*
* The five payload fields are carried over from quartz verbatim -- same names, same
* order, same `ByteArray` type on both sides of the copy -- so a value taken from the
* wrong field of the right object typechecks and reaches the database unnoticed.
* `framedCommitBytes = commitResult.preCommitExporterSecret` survived exactly that way,
* storing the group's pre-commit exporter secret in the column documented to hold a
* broadcastable MLS envelope.
*
* Mapping here rather than at the call site means it is written once, in declaration
* order, and pinned by MarmotCommitResultMappingTest.
*/
fun from(
commitEventId: HexKey,
commitResult: CommitResult,
chatRoomId: HexKey,
userPublicKey: HexKey,
peerKeyPackageEventId: HexKey,
isOneMemberInitialGroupCreation: Boolean,
createdAt: Instant,
): MarmotCommitResult = MarmotCommitResult(
id = commitEventId,
userPublicKey = userPublicKey,
peerKeyPackageEventId = peerKeyPackageEventId,
chatRoomId = chatRoomId,
isOneMemberInitialGroupCreation = isOneMemberInitialGroupCreation,
commitBytes = commitResult.commitBytes,
welcomeBytes = commitResult.welcomeBytes,
groupInfoBytes = commitResult.groupInfoBytes,
framedCommitBytes = commitResult.framedCommitBytes,
preCommitExporterSecret = commitResult.preCommitExporterSecret,
createdAt = createdAt,
)
}
override fun equals(other: Any?): Boolean {

View File

@@ -64,6 +64,15 @@ data class MarmotInnerEvent( // TODO: Rename this to GiftWrapPayload...
val content: String,
val quotedEventId: String? = null,
/**
* For a SubmissionEvent rumor, the id of the nip30303 event it carries.
*
* The submission's own id is derived from the envelope, so it is the only
* handle the queue has on what is actually being submitted. Null for every
* other kind, where the row *is* the event.
*/
val payloadEventId: HexKey? = null,
/**
* Associated MarmotGroupEvent
*/
@@ -129,6 +138,7 @@ data class MarmotInnerEvent( // TODO: Rename this to GiftWrapPayload...
if (!tags.contentDeepEquals(other.tags)) return false
if (content != other.content) return false
if (quotedEventId != other.quotedEventId) return false
if (payloadEventId != other.payloadEventId) return false
if (marmotGroupEventId != other.marmotGroupEventId) return false
if (directMessageRecipientPublicKey != other.directMessageRecipientPublicKey) return false
if (createdAt != other.createdAt) return false
@@ -149,6 +159,7 @@ data class MarmotInnerEvent( // TODO: Rename this to GiftWrapPayload...
result = 31 * result + tags.contentDeepHashCode()
result = 31 * result + content.hashCode()
result = 31 * result + (quotedEventId?.hashCode() ?: 0)
result = 31 * result + (payloadEventId?.hashCode() ?: 0)
result = 31 * result + (marmotGroupEventId?.hashCode() ?: 0)
result = 31 * result + (directMessageRecipientPublicKey?.hashCode() ?: 0)
result = 31 * result + createdAt.hashCode()

View File

@@ -0,0 +1,25 @@
package press.mantra.compose.database.model.types
/**
* How far a FROST signing session has got, from the point of view of the device
* holding the row. Coordinator and signers move through the same ladder; the
* coordinator simply has extra work to do at [COLLECTING_NONCES] and
* [COLLECTING_PARTIAL_SIGNATURES].
*
* Declaration order is the ladder: `FrostSigningManager` compares ordinals to
* keep the label moving forwards when messages arrive out of order, so the
* collecting stages must stay in the order the session runs them.
*/
enum class FrostSigningStage {
/** Proposal seen; waiting for enough signers to offer a nonce. */
COLLECTING_NONCES,
/** The signer set is fixed; waiting on their partial signatures. */
COLLECTING_PARTIAL_SIGNATURES,
/** Aggregated and verified. The event is signed. */
COMPLETE,
/** Abandoned. See `FrostSigningSession.failureReason`. */
FAILED
}

View File

@@ -3,9 +3,11 @@ package press.mantra.compose.database.repository
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.model.DkgParticipantMessage
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.types.DkgApprovalStep
import press.mantra.compose.managers.ChillDkgRitualManager
import press.mantra.compose.managers.GroupKeyStateManager
import press.mantra.compose.repository.DkgRepository
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -48,6 +50,31 @@ class DatabaseDkgRepository(
override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? =
ChillDkgRitualManager.pendingApproval(database, session)
override suspend fun announceGroupKeyState(
chatRoomId: String,
userPublicKey: HexKey,
session: DkgSession
): GroupKeyState? {
val thresholdPublicKey = session.thresholdPublicKey ?: return null
return try {
GroupKeyStateManager.announce(
database = database,
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
dkgSessionId = session.id,
thresholdPublicKey = thresholdPublicKey
)
} catch (e: Throwable) {
// The room not deriving from the key it is about to announce is a
// bug rather than a condition, but it is not worth failing the room
// over: the group still has a working chat, and signing simply falls
// back to the rederivation scan it used before there was a state.
logger.e("Error announcing the key state for $chatRoomId", e)
null
}
}
override suspend fun approve(
localChatRoom: LocalChatRoom,
sessionId: String,

View File

@@ -0,0 +1,101 @@
package press.mantra.compose.database.repository
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.model.FrostSignerMessage
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.types.FrostSigningStage
import press.mantra.compose.managers.FrostSigningManager
import press.mantra.compose.repository.FrostSigningRepository
class DatabaseFrostSigningRepository(
private val database: MantraDatabase,
private val scope: CoroutineScope
): FrostSigningRepository {
private val logger = Logger.withTag(TAG)
override fun observeSessionById(sessionId: String): Flow<FrostSigningSession?> =
database.frostSigningSessionDao().observeSessionById(sessionId)
override fun observeSessionsForChatRoom(chatRoomId: String): Flow<List<FrostSigningSession>> =
database.frostSigningSessionDao().observeSessionsForChatRoom(chatRoomId)
override fun observeMessages(sessionId: String): Flow<List<FrostSignerMessage>> =
database.frostSigningSessionDao().observeMessages(sessionId)
override suspend fun getSessionById(sessionId: String): FrostSigningSession? =
database.frostSigningSessionDao().getSessionById(sessionId)
override suspend fun liveSessionForChatRoom(chatRoomId: String): FrostSigningSession? {
val sessions = database.frostSigningSessionDao().getSessionsForChatRoom(chatRoomId)
return sessions.firstOrNull {
it.stage != FrostSigningStage.COMPLETE && it.stage != FrostSigningStage.FAILED
} ?: sessions.firstOrNull()
}
override suspend fun canSign(chatRoomId: String): Boolean =
FrostSigningManager.canSign(database, chatRoomId)
override suspend fun proposeSigning(
localChatRoom: LocalChatRoom,
userPublicKey: HexKey,
kind: Kind,
tags: Array<Array<String>>,
content: String
): FrostSigningSession? = try {
FrostSigningManager.proposeSigning(
database = database,
localChatRoom = localChatRoom,
userPublicKey = userPublicKey,
kind = kind,
tags = tags,
content = content
)
} catch (e: Throwable) {
// Proposing throws when the group has no key or this device was not in the
// ceremony. Both are states the UI is supposed to have checked for, so this
// is a null the caller reports rather than a crash.
logger.e("Error proposing a signature in ${localChatRoom.chatRoom.id}", e)
null
}
override suspend fun approve(localChatRoom: LocalChatRoom, sessionId: String) {
try {
FrostSigningManager.approve(
database = database,
localChatRoom = localChatRoom,
sessionId = sessionId
)
} catch (e: Throwable) {
// The session fails itself and tells the group; swallowing here keeps a
// protocol fault from taking the screen down with it.
logger.e("Error approving signing session $sessionId", e)
}
}
override suspend fun decline(localChatRoom: LocalChatRoom, sessionId: String) {
try {
FrostSigningManager.decline(
database = database,
localChatRoom = localChatRoom,
sessionId = sessionId
)
} catch (e: Throwable) {
logger.e("Error declining signing session $sessionId", e)
}
}
override fun signedEvent(session: FrostSigningSession): Event? =
FrostSigningManager.signedEvent(session)
companion object {
private const val TAG = "DatabaseFrostSigningRepository"
}
}

View File

@@ -44,7 +44,7 @@ class DatabaseMantraRepository(
dialectId: String,
chatRoomId: String,
userPublicKey: HexKey,
): MarmotInnerEvent? {
): MantraTranslationArtifactVersion? {
return database.mantraDao().addTranslationArtifactVersion(
artifactId = artifactId,
dialectId = dialectId,
@@ -99,7 +99,7 @@ class DatabaseMantraRepository(
originalText: String,
chatRoomId: String,
userPublicKey: HexKey,
): MarmotInnerEvent? {
): MantraChapter? {
return database.mantraDao().addChapter(
artifactId = artifactId,
name = name,
@@ -121,7 +121,7 @@ class DatabaseMantraRepository(
country: String,
language: String,
userPublicKey: HexKey,
): MarmotInnerEvent? {
): MantraDialect? {
return database.mantraDao().addDialect(
localChatRoom = localChatRoom,
name = name,
@@ -140,7 +140,7 @@ class DatabaseMantraRepository(
userPublicKey: HexKey,
visibility: String,
license: String,
): MarmotInnerEvent? {
): MantraArtifact? {
return database.mantraDao().addArtifact(
localChatRoom = localChatRoom,
name = name,
@@ -158,7 +158,7 @@ class DatabaseMantraRepository(
artifactId: HexKey,
versionLabel: String,
userPublicKey: HexKey,
): MarmotInnerEvent? {
): MantraArtifactVersion? {
return database.mantraDao().addArtifactVersion(
localChatRoom = localChatRoom,
artifactId = artifactId,

View File

@@ -574,6 +574,12 @@ object ChillDkgRitualManager {
stage = DkgRitualStage.COMPLETE,
thresholdPublicKey = output.thresholdPublicKey?.value?.toHex(),
secretShare = output.secretShare?.value?.toHex(),
// Kept for signing, which needs every participant's public
// share to place and check the signers. In participant order,
// the same order the ids are derived from.
publicShares = output.publicShares
.joinToString(",") { share -> share.value.toHex() }
.takeIf { output.publicShares.isNotEmpty() },
recoveryData = output.recovery?.toHex()
)
}

View File

@@ -0,0 +1,177 @@
package press.mantra.compose.managers
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import kotlin.time.Clock
import kotlin.time.Instant
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.database.model.MarmotInnerEvent
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
/**
* Announces and files what key a room signs with.
*
* The coordinator [announce]s once, as the new room's first message; every
* other member [record]s what arrives. Both ends land on the same
* [GroupKeyState] row, which is what a signing request is resolved against --
* see `FrostSigningManager.completedKey`.
*
* Nothing here is trusted on the strength of who said it. A state is kept only
* if the room's id rederives from the key it names, which is the same check
* `completedKey` used to make by scanning, and the reason a coordinator cannot
* point a room at a key it was not made from.
*/
object GroupKeyStateManager {
private const val TAG = "GroupKeyStateManager"
private val logger = Logger.withTag(TAG)
/** The key state a room signs under, or null while it has none. */
suspend fun keyStateFor(database: MantraDatabase, chatRoomId: String): GroupKeyState? =
database.groupKeyStateDao().getByChatRoomId(chatRoomId)
/**
* Says what the freshly made room signs with, and files it locally.
*
* Called once, by the member who created the room, before anybody has been
* added to it -- the announcement is the room's first message, so a member
* arriving on a welcome finds it waiting rather than having to be told
* separately.
*
* Queued before it is recorded, matching the signing pipeline: a crash
* between the two costs a duplicate announcement, which [record] folds
* away, rather than a room whose key nobody ever named.
*
* Refuses to announce a state that does not describe the room, because a
* state that fails [GroupKeyState.verifies] here is this device having
* derived the room from one key and announced another -- a bug worth
* failing on rather than broadcasting.
*/
suspend fun announce(
database: MantraDatabase,
chatRoomId: String,
userPublicKey: HexKey,
dkgSessionId: String,
thresholdPublicKey: HexKey,
path: List<Long> = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH,
createdAt: Long = Clock.System.now().epochSeconds
): GroupKeyState {
val state = GroupKeyState(
chatRoomId = chatRoomId,
dkgSessionId = dkgSessionId,
thresholdPublicKey = thresholdPublicKey,
derivationPath = SharedKeyDerivation.formatPath(path),
announcedBy = userPublicKey,
announcedAt = Instant.fromEpochSeconds(createdAt)
)
check(state.verifies()) {
"Room $chatRoomId is not derived from $thresholdPublicKey at ${state.derivationPath}"
}
val tags = GroupKeyStateEvent.assembleTags(
chatRoomId = chatRoomId,
dkgSessionId = dkgSessionId,
path = path
)
database.marmotInnerEventDao().upsert(
MarmotInnerEvent(
// The rumor id the outbound pipeline will recompute from these
// same fields when it assembles the event to encrypt.
id = EventHasher.hashId(
pubKey = userPublicKey,
createdAt = createdAt,
tags = tags,
content = thresholdPublicKey,
kind = GroupKeyStateEvent.KIND
),
publicKey = userPublicKey,
kind = GroupKeyStateEvent.KIND,
createdAt = Instant.fromEpochSeconds(createdAt),
tags = tags,
content = thresholdPublicKey,
chatRoomId = chatRoomId
)
)
logger.i("Announcing key ${state.thresholdPublicKey} for room $chatRoomId at ${state.derivationPath}")
return database.groupKeyStateDao().replace(state)
}
/**
* Files an inbound announcement, or drops it and says why.
*
* Storing is all this adds to [stateFrom], which is where the deciding
* happens -- kept apart so the check a member's safety rests on can be
* exercised without standing up a database.
*/
suspend fun record(
database: MantraDatabase,
chatRoomId: String,
innerEvent: Event
): GroupKeyState? =
stateFrom(chatRoomId, innerEvent)?.let { database.groupKeyStateDao().replace(it) }
/**
* The state an announcement amounts to, or null if it amounts to none.
*
* Every reason to return null is a reason the announcement does not describe
* this room, and none of them are about who sent it: a member with no share,
* or none of the ceremony at all, can announce a true state and it is still
* true. What cannot be tolerated is a state naming a key the room was not
* derived from, because acting on one means signing with a share that will
* not aggregate -- or, worse, treating a key the group does not hold as the
* key the group holds.
*/
fun stateFrom(chatRoomId: String, innerEvent: Event): GroupKeyState? {
val announced = GroupKeyStateEvent.parseChatRoomId(innerEvent.tags)
if (announced != null && announced != chatRoomId) {
logger.w("Key state for room $announced arrived in $chatRoomId; dropping")
return null
}
val thresholdPublicKey = GroupKeyStateEvent.parseThresholdPublicKey(innerEvent.content)
if (thresholdPublicKey == null) {
logger.w("Key state in $chatRoomId carries no threshold key; dropping")
return null
}
val dkgSessionId = GroupKeyStateEvent.parseDkgSessionId(innerEvent.tags)
if (dkgSessionId == null) {
logger.w("Key state in $chatRoomId names no ceremony; dropping")
return null
}
val path = GroupKeyStateEvent.parsePath(innerEvent.tags)
if (path == null) {
logger.w("Key state in $chatRoomId carries no walkable derivation path; dropping")
return null
}
val state = GroupKeyState(
chatRoomId = chatRoomId,
dkgSessionId = dkgSessionId,
thresholdPublicKey = thresholdPublicKey,
derivationPath = SharedKeyDerivation.formatPath(path),
announcedBy = innerEvent.pubKey,
announcedAt = Instant.fromEpochSeconds(innerEvent.createdAt)
)
// The whole trust model, in one line. Anybody may say what this room
// signs with; only the truth rederives the room they said it in.
if (!state.verifies()) {
logger.w(
"Key state from ${innerEvent.pubKey} names $thresholdPublicKey at " +
"${state.derivationPath}, which does not derive room $chatRoomId; dropping"
)
return null
}
return state
}
}

View File

@@ -0,0 +1,152 @@
package press.mantra.compose.managers
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
import press.mantra.compose.extensions.toHex
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Keeps a room's [MlsGroup] alive between messages instead of rebuilding it
* from the stored state every time.
*
* ### The bug this exists for
*
* MLS is specified to tolerate out-of-order delivery within an epoch: a
* receiver that gets generation N+1 before N derives and caches the key for N
* so the older message can still be read when it turns up. Quartz's
* `SecretTree` does exactly that, in a private `skippedKeys` map.
*
* `SecretTree.exportSenderStates()` does not include that map, so
* `MlsGroup.saveState()` does not carry it. Rebuilding the group from stored
* state therefore throws the skipped keys away, and a message for a generation
* the ratchet has already passed fails
* `require(generation >= state.applicationGeneration)` and is dropped. There is
* no recovering it afterwards: the key is gone and the sender will not resend.
*
* Nostr relays offer no ordering whatsoever, so this is not an edge case. Two
* events published in the same second race, and exactly one survives — which is
* how a signing session's proposal was lost while the nonce sent immediately
* behind it arrived fine.
*
* ### What this fixes, and what it does not
*
* Holding the instance means `skippedKeys` survives for as long as the process
* does and nothing else writes the room's state. That covers the case that
* actually bites — a burst of messages arriving in one sync — because they are
* decrypted one after another against the same tree.
*
* It does not survive a restart, and it does not survive another writer, so
* reordering across app launches still loses messages. The real fix is for
* `exportSenderStates` to carry the skipped keys; see
* `docs/mls-skipped-keys.md`.
*
* ### Staleness
*
* The group is only reused when the stored state is still exactly what this
* cache last wrote. Anything else that saves a room's state — sending a message
* advances the sender ratchet and saves, so does adding a member — changes the
* hex, and the next read rebuilds rather than carrying on from a group that has
* been overtaken. Losing the skipped keys there is the same behaviour as
* before this existed, so the fallback is never worse than not caching.
*/
object MlsGroupCache {
private val cache = LiveInstanceCache<MlsGroup> { it.saveState().encodeTls().toHex() }
/**
* Runs [block] against the room's live group, then stores whatever state it
* left behind.
*
* [storedStateHex] is the room's state as the database currently has it, and
* [build] turns it into a group. [save] is handed the state to persist.
*
* Returns null without calling [block] when the room has no usable group
* state, which is the same thing a failed `toMlsGroup()` meant before.
*/
suspend fun <T> withGroup(
chatRoomId: String,
storedStateHex: String?,
build: () -> MlsGroup?,
save: suspend (String) -> Unit,
block: suspend (MlsGroup) -> T,
): T? = cache.withInstance(
key = chatRoomId,
storedState = storedStateHex,
build = build,
save = save,
block = block
)
}
/**
* One live instance per key, reused only while the stored state is still the one
* this cache last wrote.
*
* Split out from [MlsGroupCache] so the decision it makes can be tested without
* standing up an MLS group. That decision is the whole safety argument: reuse
* when nothing else has written, rebuild when something has, and never carry on
* with an instance whose last use failed part-way through.
*/
internal class LiveInstanceCache<T : Any>(
/** The persisted form of an instance, for spotting another writer. */
private val stateOf: (T) -> String,
) {
private val logger = Logger.withTag("LiveInstanceCache")
private class Entry<T>(val instance: T, val state: String)
private val entries = mutableMapOf<String, Entry<T>>()
/**
* Serialises use of one key's instance.
*
* The instance is mutable and [block] advances it, so two callers running at
* once would corrupt it. One lock per key rather than one overall, so a busy
* key cannot hold up a quiet one.
*
* Held across [block], which may touch the database. Safe here because a
* caller only ever takes this lock while it is already running -- it never
* waits on a resource the holder is itself waiting for.
*/
private val locks = mutableMapOf<String, Mutex>()
private val locksGuard = Mutex()
private suspend fun lockFor(key: String): Mutex =
locksGuard.withLock { locks.getOrPut(key) { Mutex() } }
suspend fun <R> withInstance(
key: String,
storedState: String?,
build: () -> T?,
save: suspend (String) -> Unit,
block: suspend (T) -> R,
): R? = lockFor(key).withLock {
val cached = entries[key]
val instance = if (cached != null && cached.state == storedState) {
cached.instance
} else {
if (cached != null) {
logger.d("$key was written elsewhere; rebuilding")
}
// Dropped before the block runs, so a build that fails does not leave
// the old instance behind to be picked up by the next caller.
entries.remove(key)
build() ?: return@withLock null
}
// Deliberately not in a finally: an instance whose use threw part-way is
// in an unknown state, and the next caller should rebuild from whatever
// was last persisted rather than carry on with it.
val result = block(instance)
val state = stateOf(instance)
save(state)
entries[key] = Entry(instance = instance, state = state)
result
}
/** How many instances are held. For tests. */
internal fun size(): Int = entries.size
}

View File

@@ -115,12 +115,40 @@ object SharedKeyDerivation {
?.firstOrNull { it.trimStart().startsWith(PATH_MARKER) }
?: return null
val path = line.trimStart().removePrefix(PATH_MARKER).trim()
return parsePathString(line.trimStart().removePrefix(PATH_MARKER).trim())
}
/** The indices [tweakScalar] can actually tell apart: a BIP32-shaped uint32. */
private val INDEX_RANGE = 0L..0xFFFFFFFFL
/**
* A bare `m/9420/0/0` as indices, or null if it is not one.
*
* Split out from [parsePath] because a path also travels on its own, in a
* `GroupKeyStateEvent`'s [press.mantra.compose.nostr.frost.tags.FrostDerivationPathTag],
* where there is no description to dig it out of. Both spellings have to
* agree on what a path is, so there is only one reader of one.
*
* Indices outside a uint32 are rejected, which matters because a path now
* arrives from the wire rather than only from [MARMOT_ADMIN_GROUP_PATH].
* [tweakScalar] serialises an index as its low four bytes, so without this
* `m/4294967296/0/0` walks to the same key as `m/0/0/0` and a room could be
* described by a path nobody would write. Nothing is stolen by that -- a
* state still has to derive the room it names -- but it would make
* [formatPath] a lossy round trip and leave two spellings of one path for
* any later code to disagree over. Negative indices go the same way: they
* are not a thing a path has.
*/
fun parsePathString(path: String): List<Long>? {
if (!path.startsWith("m/")) return null
return path.removePrefix("m/")
.split("/")
.map { segment -> segment.toLongOrNull() ?: return null }
.map { segment ->
val index = segment.toLongOrNull() ?: return null
if (index !in INDEX_RANGE) return null
index
}
.takeIf { it.isNotEmpty() }
}

View File

@@ -0,0 +1,37 @@
package press.mantra.compose.nostr
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import press.mantra.compose.database.model.types.SynchronizationFilter
/**
* The one filter shape that can return a NIP-17 message we are able to read.
*
* A gift wrap hides everything except who it is for. The author is the throwaway
* key [GiftWrapEvent.create] mints and discards, the content is sealed to the
* recipient, and `created_at` is randomised up to two days into the past. That
* leaves the `p` tag as the only clause worth writing, and it has to name us:
* naming a peer subscribes to mail no key of ours can open, and adding `authors`
* matches nothing on any relay, ever. Both mistakes were live in three separate
* call sites, so the filter is built in one place now and asserted in one place.
*/
object Nip17Filters {
/**
* Everything gift-wrapped to [publicKey], capped at [limit] events.
*
* Deliberately carries no `since`. NIP-59 back-dates a wrap by up to two days,
* so a cursor built from the newest wrap we hold silently skips mail that was
* sent later but stamped earlier.
*/
fun inbox(
publicKey: HexKey,
limit: Int = DEFAULT_LIMIT,
) = SynchronizationFilter(
kinds = arrayOf(GiftWrapEvent.KIND),
tags = mapOf("p" to listOf(publicKey)),
limit = limit,
)
const val DEFAULT_LIMIT = 50
}

View File

@@ -0,0 +1,107 @@
package press.mantra.compose.nostr.frost
import com.vitorpamplona.quartz.nip01Core.core.Kind
import press.mantra.compose.nostr.frost.tags.FrostKeyTag
import press.mantra.compose.nostr.frost.tags.FrostSessionIdTag
import press.mantra.compose.nostr.frost.tags.FrostSignerIdsTag
/**
* The nostr kinds a FROST signing session is carried on.
*
* These are **rumor** kinds: they only ever exist inside a Marmot group event,
* MLS-encrypted to the group and then wrapped again under the group's exporter
* secret, so no relay sees them and the replaceable semantics normally implied
* by the 3xxxx range never apply.
*
* Who talks to whom, in order:
*
* ```
* proposer --[ 30320 proposal ]-> everyone the unsigned event to sign
* signer --[ 30321 nonce ]-> everyone this device's public nonce
* proposer --[ 30322 signer set ]-> everyone who is signing, and their aggregated nonce
* signer --[ 30323 partial ]-> everyone this device's partial signature
* proposer --[ 30324 signature ]-> everyone the finished 64-byte signature
* anyone --[ 30325 failure ]-> everyone abandon + blame
* ```
*
* [GroupKeyStateEvent] sits just past them on 30326. It is not part of a
* session -- it is the standing fact a session is opened against, saying which
* key the room signs with -- so it is deliberately outside [ALL], which is what
* the inbound path dispatches a session message on.
*
* ### Why 3032x
*
* These share the inner-event space with the nip30303 document kinds, which run
* 30300 up to [press.mantra.compose.nostr.nip30303.SubmissionEvent] at 30312 --
* the same space, because both are Marmot inner events and both are dispatched
* on kind by the same inbound path. Starting at 30320 leaves that family room to
* grow into.
*
* The DKG's 30310-30316 look like a clash and are not: those exist only inside
* NIP-17 gift wraps, and nothing reads a kind across both transports. It is
* worth knowing that the numbers already overlap there -- the DKG's proposal and
* host-key kinds sit on 30310 and 30311 alongside two nip30303 kinds, and its
* round-1 kind is 30312, alongside SubmissionEvent -- because that separation is
* an accident of routing rather than a decision, and the next family added
* should not rely on it.
*/
object FrostSigningEvents {
/**
* Opens a session. Content is the unsigned nostr event, as JSON; the key to
* sign with is named in [FrostKeyTag].
*/
val PROPOSAL: Kind = 30320
/** A signer's `IndividualNonce`, hex encoded. */
val NONCE: Kind = 30321
/**
* The coordinator's chosen signers, in [FrostSignerIdsTag], with their
* `AggregatedNonce` as the content, hex encoded.
*/
val SIGNER_SET: Kind = 30322
/** A signer's 32-byte partial signature, hex encoded. */
val PARTIAL_SIGNATURE: Kind = 30323
/** The finished 64-byte BIP-340 signature over the event id, hex encoded. */
val SIGNATURE: Kind = 30324
/** Session abandoned. Content is the reason, for showing to the group. */
val FAILURE: Kind = 30325
/** Every kind above, for filtering inbound payloads in one check. */
val ALL: Set<Kind> = setOf(
PROPOSAL,
NONCE,
SIGNER_SET,
PARTIAL_SIGNATURE,
SIGNATURE,
FAILURE
)
fun isFrostSigningKind(kind: Kind): Boolean = kind in ALL
/**
* Tags for a signing message. The session id is on every kind so a message
* from an abandoned attempt can be dropped rather than mixed in.
*/
fun assembleTags(
sessionId: String,
dkgSessionId: String? = null,
signerIds: List<Int>? = null
): Array<Array<String>> = buildList {
add(FrostSessionIdTag.assemble(sessionId))
dkgSessionId?.let { add(FrostKeyTag.assemble(it)) }
signerIds?.let { add(FrostSignerIdsTag.assemble(it)) }
}.toTypedArray()
fun parseSessionId(tags: Array<Array<String>>): String? =
tags.firstNotNullOfOrNull(FrostSessionIdTag::parse)?.sessionId
fun parseKey(tags: Array<Array<String>>): String? =
tags.firstNotNullOfOrNull(FrostKeyTag::parse)?.dkgSessionId
fun parseSignerIds(tags: Array<Array<String>>): List<Int>? =
tags.firstNotNullOfOrNull(FrostSignerIdsTag::parse)?.signerIds
}

View File

@@ -0,0 +1,108 @@
package press.mantra.compose.nostr.frost
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag
import press.mantra.compose.managers.SharedKeyDerivation
import press.mantra.compose.nostr.frost.tags.FrostDerivationPathTag
import press.mantra.compose.nostr.frost.tags.FrostKeyTag
/**
* What key a Marmot room signs with, announced into the room itself.
*
* The coordinator posts one as the room's first message, right after creating
* it. Content is the group's ChillDKG threshold public key; the tags name the
* ceremony that produced it and the path the room's id was derived at.
*
* ```
* coordinator --[ 30326 group key state ]-> everyone "this room signs with K, at m/9420/0/0"
* ```
*
* ### What it is for
*
* A signer holds a different secret share under every ceremony it took part in,
* and signing with the wrong one produces a partial signature that cannot
* aggregate. This is the record that says which. A member reads the state,
* follows [FrostKeyTag] to the `DkgSession` row their own device already holds,
* and takes the share from there -- the association travels, the share does not.
*
* Nothing secret is in here, and that is not an accident. Every member of the
* room can read it, so a share put on this event would be every member holding
* every other member's share, which is a 1-of-n key wearing a t-of-n's clothes.
*
* ### Trusted no further than it can be checked
*
* The coordinator posts it, and the coordinator is untrusted by construction.
* A receiver therefore verifies rather than believes: the room's id *is* the
* threshold key derived at the path, so
* `SharedKeyDerivation.marmotGroupId(content, path) == chatRoomId` has to hold
* or the state is dropped. That is the same check
* `FrostSigningManager.completedKey` made by rederiving, kept rather than
* replaced -- this event makes the association explicit and cheap to look up,
* not easier to forge.
*
* ### Replaceable, by this app rather than by a relay
*
* Like every kind in [FrostSigningEvents] this is a rumor inside a Marmot group
* event, so no relay ever sees it and the addressable semantics of the 3xxxx
* range never fire. [DTag] is the room id and the newest state per room wins,
* which the local store enforces on its own. Being able to say it twice is what
* matters in practice: a redelivered announcement, or a second member saying the
* same true thing, folds away instead of accumulating.
*
* One room only ever names one key today. A group that re-runs its ceremony
* derives a *different* room from the new key, so rotation in place does not
* arise -- and if it ever does, the verification above is what has to change
* first, because a rotated key no longer derives the room it is announced in.
*/
object GroupKeyStateEvent {
/**
* Sits with the signing family in the Marmot inner-event space. 30320-30325
* are a signing session; this is the standing fact a session is opened
* against, so it is adjacent rather than inside.
*/
val KIND: Kind = 30326
fun isGroupKeyStateKind(kind: Kind): Boolean = kind == KIND
/**
* The tags for a state naming [dkgSessionId], for the room derived at [path].
*
* The room id goes on as the `d` tag so the event is self-addressing: a
* reader can tell which room a state belongs to without the envelope it
* arrived in, which is what makes dropping a state announced into the wrong
* room a check rather than an assumption.
*/
fun assembleTags(
chatRoomId: String,
dkgSessionId: String,
path: List<Long> = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
): Array<Array<String>> = arrayOf(
DTag.assemble(chatRoomId),
FrostKeyTag.assemble(dkgSessionId),
FrostDerivationPathTag.assemble(path)
)
/** The room this state is about, or null if it names none. */
fun parseChatRoomId(tags: Array<Array<String>>): String? =
tags.firstOrNull { it.size > 1 && it[0] == DTag.TAG_NAME }?.get(1)?.ifBlank { null }
/** The ceremony whose share signs for this room, or null if it names none. */
fun parseDkgSessionId(tags: Array<Array<String>>): String? =
tags.firstNotNullOfOrNull(FrostKeyTag::parse)?.dkgSessionId
/** The derivation path, or null if it carries none or an unwalkable one. */
fun parsePath(tags: Array<Array<String>>): List<Long>? =
tags.firstNotNullOfOrNull(FrostDerivationPathTag::parse)?.path
/**
* The threshold public key a state announces, or null when the content is
* not one.
*
* Shape only -- 33 compressed bytes of hex. Whether it is *the* key for the
* room is settled by rederiving the room id from it, not by looking at it.
*/
fun parseThresholdPublicKey(content: String): HexKey? =
content.trim()
.takeIf { it.length == 66 && it.all { char -> char.isDigit() || char in 'a'..'f' || char in 'A'..'F' } }
}

View File

@@ -0,0 +1,42 @@
package press.mantra.compose.nostr.frost.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
import press.mantra.compose.managers.SharedKeyDerivation
/**
* The path the room's id was derived at, `m/9420/0/0` style.
*
* Recorded rather than assumed. `SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH`
* is the only path anything walks today, but a room derived at a second one
* would be unfindable by a lookup that hardcodes the first, and the path is
* also what rebuilds the `TweakCache` a signing session needs.
*
* Hardened indices are rejected on parse: hardened derivation needs the parent
* private key, which in a threshold group nobody has, so a path carrying one
* was never walked.
*/
class FrostDerivationPathTag(
val path: List<Long>,
) {
fun toTagArray() = assemble(path = path)
companion object {
const val TAG_NAME = "frost_path"
fun parse(tag: Array<String>): FrostDerivationPathTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
val path = SharedKeyDerivation.parsePathString(tag[1]) ?: return null
return FrostDerivationPathTag(path = path)
}
fun assemble(path: List<Long>): Array<String> =
arrayOf(TAG_NAME, SharedKeyDerivation.formatPath(path))
fun assemble(frostDerivationPathTag: FrostDerivationPathTag) =
assemble(path = frostDerivationPathTag.path)
}
}

View File

@@ -0,0 +1,36 @@
package press.mantra.compose.nostr.frost.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* Which key the group is being asked to sign with, named by the ceremony that
* created it.
*
* A group can hold more than one shared key -- a ceremony is re-runnable, and
* a member leaving is a reason to run another -- and a signer holds a
* different secret share under each. Signing with the share from the wrong
* ceremony produces a partial signature that cannot aggregate, so the session
* says which one from the start rather than leaving each device to guess at
* its most recent.
*/
class FrostKeyTag(
val dkgSessionId: String,
) {
fun toTagArray() = assemble(dkgSessionId = dkgSessionId)
companion object {
const val TAG_NAME = "frost_key"
fun parse(tag: Array<String>): FrostKeyTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
return FrostKeyTag(dkgSessionId = tag[1])
}
fun assemble(dkgSessionId: String): Array<String> = arrayOf(TAG_NAME, dkgSessionId)
fun assemble(frostKeyTag: FrostKeyTag) = assemble(dkgSessionId = frostKeyTag.dkgSessionId)
}
}

View File

@@ -0,0 +1,36 @@
package press.mantra.compose.nostr.frost.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* Binds a signing message to one session.
*
* A group signs more than once with the same key, and an abandoned attempt at
* signing one event must never have its messages fed into a live attempt at
* another. That is stricter here than it is for a DKG: every signer's secret
* nonce is derived per session, so two sessions sharing an id would be two
* different messages signed under one nonce -- which is how a secret share
* leaks.
*/
class FrostSessionIdTag(
val sessionId: String,
) {
fun toTagArray() = assemble(sessionId = sessionId)
companion object {
const val TAG_NAME = "frost_session"
fun parse(tag: Array<String>): FrostSessionIdTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
return FrostSessionIdTag(sessionId = tag[1])
}
fun assemble(sessionId: String): Array<String> = arrayOf(TAG_NAME, sessionId)
fun assemble(frostSessionIdTag: FrostSessionIdTag) =
assemble(sessionId = frostSessionIdTag.sessionId)
}
}

View File

@@ -0,0 +1,42 @@
package press.mantra.compose.nostr.frost.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* The participants the coordinator chose to sign with, as their FROST ids, in
* the order their nonces were aggregated.
*
* A t-of-n key does not need everyone, so somebody has to pick which t, and
* only the coordinator sees every nonce. Every signer then has to build the
* same session from the same set in the same order -- FROST binds the set into
* the challenge, so a device that disagrees about who is signing produces a
* partial signature that will not aggregate.
*/
class FrostSignerIdsTag(
val signerIds: List<Int>,
) {
fun toTagArray() = assemble(signerIds = signerIds)
companion object {
const val TAG_NAME = "frost_signers"
fun parse(tag: Array<String>): FrostSignerIdsTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
// Order is meaningful, so a single unparseable entry invalidates the
// whole list rather than quietly shortening it.
val ids = tag[1].split(",").map { it.trim().toIntOrNull() ?: return null }
if (ids.isEmpty()) return null
return FrostSignerIdsTag(signerIds = ids)
}
fun assemble(signerIds: List<Int>): Array<String> =
arrayOf(TAG_NAME, signerIds.joinToString(","))
fun assemble(frostSignerIdsTag: FrostSignerIdsTag) =
assemble(signerIds = frostSignerIdsTag.signerIds)
}
}

View File

@@ -0,0 +1,81 @@
package press.mantra.compose.nostr.nip30303
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.utils.TimeUtils
import press.mantra.compose.nostr.nip30303.tags.PayloadIdTag
import press.mantra.compose.nostr.nip30303.tags.PayloadKindTag
/**
* 30312
*
* An envelope that carries one nip30303 event into a group.
*
* Every other nip30303 kind describes a thing -- an artifact, a dialect, a
* chapter, a translated chunk. A submission describes an act: *this member is
* putting this event in front of this group*. The two are separate on purpose,
* because they answer different questions and often have different answers.
*
* The payload travels whole, in [content], keeping its own id, author and
* signature. Nothing is rewritten to make it look like the submitter's work.
* That buys two things:
*
* - A group can take in work written by somebody who is not in it. A
* translation lifted from a public archive, a chapter transcribed by an
* outside contributor, an artifact somebody published years ago -- an admin
* submits it and the group applies it, with the original author still named
* on the row.
* - Authorship stops being a claim the transport makes. Before this, being
* the sender of a group message *was* being the author of the event inside
* it, so the only events a group could hold were ones its own members had
* written under their own keys.
*
* A submission is not an endorsement and grants nothing: a payload's author is
* whoever signed it, and who may submit is the group's business.
*/
@Immutable
class SubmissionEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
/**
* The submitted event, parsed out of [content].
*
* Null when the content is not an event at all -- treat that as a
* submission that cannot be applied, not as an empty one.
*/
fun payload(): Event? = Event.fromJsonOrNull(content)
fun payloadKind() = tags.firstNotNullOfOrNull(PayloadKindTag::parse)?.kind
fun payloadIdReference() = tags.firstNotNullOfOrNull(PayloadIdTag::parse)?.ref
fun payloadId() = payloadIdReference()?.eventId
/** Who wrote the payload, which is not who sent this submission. */
fun payloadAuthor() = payloadIdReference()?.author
companion object {
const val KIND = 30312
const val ALT_DESCRIPTION = "Submission"
fun build(
payload: Event,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<SubmissionEvent>.() -> Unit = {},
) = eventTemplate(KIND, payload.toJson(), createdAt) {
alt(ALT_DESCRIPTION)
addUnique(PayloadKindTag.assemble(payload.kind))
addUnique(PayloadIdTag.assemble(eventId = payload.id, pubkey = payload.pubKey))
initializer()
}
}
}

View File

@@ -0,0 +1,47 @@
package press.mantra.compose.nostr.nip30303.tags
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.tags.events.EventReference
import com.vitorpamplona.quartz.utils.arrayOfNotNull
import com.vitorpamplona.quartz.utils.ensure
/**
* The event a submission carries, named on the submission itself.
*
* The payload is already in the submission's content, so this tag is not how a
* reader gets at it -- it is how they filter for it. The author slot matters
* more than usual here: on a submission it names whoever wrote the payload,
* who is not necessarily the member who submitted it.
*/
data class PayloadIdTag(
val ref: EventReference,
) {
constructor(eventId: String, relayHint: NormalizedRelayUrl?, pubkey: String?) : this(
EventReference(eventId, pubkey, relayHint)
)
companion object {
const val TAG_NAME = "payloadId"
fun parse(tag: Array<String>): PayloadIdTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) }
return PayloadIdTag(tag[1], relayHint, tag.getOrNull(3))
}
fun assemble(
eventId: HexKey,
relay: NormalizedRelayUrl? = null,
pubkey: String? = null,
) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, pubkey)
fun assemble(ref: EventReference) = assemble(ref.eventId, ref.relayHint, ref.author)
}
}

View File

@@ -0,0 +1,43 @@
package press.mantra.compose.nostr.nip30303.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* The kind of the event a submission carries.
*
* Lets a client decide whether it can apply a submission without parsing the
* payload JSON out of the content first.
*/
class PayloadKindTag(
val kind: Int,
) {
fun toTagArray() = assemble(
kind = kind,
)
companion object {
const val TAG_NAME = "payloadKind"
fun parse(tag: Array<String>): PayloadKindTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
val kind = tag[1].toIntOrNull() ?: return null
return PayloadKindTag(
kind = kind,
)
}
fun assemble(
kind: Int,
): Array<String> = arrayOf(
TAG_NAME,
kind.toString()
)
fun assemble(payloadKindTag: PayloadKindTag) = assemble(
kind = payloadKindTag.kind,
)
}
}

View File

@@ -2,6 +2,7 @@ package press.mantra.compose.repository
import press.mantra.compose.database.model.DkgParticipantMessage
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.types.DkgApprovalStep
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -42,6 +43,19 @@ interface DkgRepository {
nostrPrivateKey: ByteArray
)
/**
* Tells [chatRoomId] which ceremony's key it signs with, as its first message.
*
* Called once by whoever creates the room. Null if the ritual has produced
* no key yet, or if the room does not derive from the one it produced --
* both of which mean there is nothing true to announce.
*/
suspend fun announceGroupKeyState(
chatRoomId: String,
userPublicKey: HexKey,
session: DkgSession
): GroupKeyState?
companion object {
val NO_OP_DKG_REPOSITORY: DkgRepository = object : DkgRepository {
override fun observeLatestSessionForChatRoom(chatRoomId: String): Flow<DkgSession?> = flowOf(null)
@@ -65,6 +79,12 @@ interface DkgRepository {
step: DkgApprovalStep,
nostrPrivateKey: ByteArray
) = Unit
override suspend fun announceGroupKeyState(
chatRoomId: String,
userPublicKey: HexKey,
session: DkgSession
): GroupKeyState? = null
}
}
}

View File

@@ -0,0 +1,94 @@
package press.mantra.compose.repository
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import press.mantra.compose.database.model.FrostSignerMessage
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
/**
* Reads, opens and answers FROST signing sessions.
*
* Advancing one is the inbound path's job, with one exception: a session waiting
* on this device's owner does not advance until [approve] is called, because it
* will not sign on their behalf until they say so.
*/
interface FrostSigningRepository {
fun observeSessionById(sessionId: String): Flow<FrostSigningSession?>
/** Every session the room has run, newest first. Unlike a ceremony, signing recurs. */
fun observeSessionsForChatRoom(chatRoomId: String): Flow<List<FrostSigningSession>>
fun observeMessages(sessionId: String): Flow<List<FrostSignerMessage>>
suspend fun getSessionById(sessionId: String): FrostSigningSession?
/**
* The session a room is currently running, or its most recent one if none is.
*
* For callers that mean "the signing going on here" without holding an id --
* a transcript line, mostly. Prefers a live session because that is the one
* anybody tapping through wants to act on; a finished one is only what is
* left to show when there is nothing live.
*/
suspend fun liveSessionForChatRoom(chatRoomId: String): FrostSigningSession?
/** Whether this room holds a key it can sign with, so the UI offers nothing that would fail. */
suspend fun canSign(chatRoomId: String): Boolean
/**
* Opens a session asking the group to sign an event with these fields. The
* author is the group's key, not the proposer's, and is filled in here.
*/
suspend fun proposeSigning(
localChatRoom: LocalChatRoom,
userPublicKey: HexKey,
kind: Kind,
tags: Array<Array<String>>,
content: String
): FrostSigningSession?
/** Agrees to sign, letting the session publish this device's part and run on. */
suspend fun approve(localChatRoom: LocalChatRoom, sessionId: String)
/** Refuses, and says so, since a t-of-n group can proceed without this member. */
suspend fun decline(localChatRoom: LocalChatRoom, sessionId: String)
/** The finished event, or null while the session is still running. */
fun signedEvent(session: FrostSigningSession): Event?
companion object {
val NO_OP_FROST_SIGNING_REPOSITORY: FrostSigningRepository = object : FrostSigningRepository {
override fun observeSessionById(sessionId: String): Flow<FrostSigningSession?> = flowOf(null)
override fun observeSessionsForChatRoom(chatRoomId: String): Flow<List<FrostSigningSession>> =
flowOf(emptyList())
override fun observeMessages(sessionId: String): Flow<List<FrostSignerMessage>> =
flowOf(emptyList())
override suspend fun getSessionById(sessionId: String): FrostSigningSession? = null
override suspend fun liveSessionForChatRoom(chatRoomId: String): FrostSigningSession? = null
override suspend fun canSign(chatRoomId: String): Boolean = false
override suspend fun proposeSigning(
localChatRoom: LocalChatRoom,
userPublicKey: HexKey,
kind: Kind,
tags: Array<Array<String>>,
content: String
): FrostSigningSession? = null
override suspend fun approve(localChatRoom: LocalChatRoom, sessionId: String) = Unit
override suspend fun decline(localChatRoom: LocalChatRoom, sessionId: String) = Unit
override fun signedEvent(session: FrostSigningSession): Event? = null
}
}
}

View File

@@ -9,7 +9,6 @@ import press.mantra.compose.database.model.MantraDialect
import press.mantra.compose.database.model.MantraTranslationArtifactVersion
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
interface MantraRepository {
@@ -65,7 +64,7 @@ interface MantraRepository {
dialectId: String,
chatRoomId: String,
userPublicKey: HexKey,
): MarmotInnerEvent?
): MantraTranslationArtifactVersion?
/**
* Add a chapter (markdown [originalText]) to the artifact's latest version.
@@ -79,7 +78,7 @@ interface MantraRepository {
originalText: String,
chatRoomId: String,
userPublicKey: HexKey,
): MarmotInnerEvent?
): MantraChapter?
suspend fun getDialects(chatRoomId: String): List<MantraDialect>
@@ -91,7 +90,7 @@ interface MantraRepository {
country: String,
language: String,
userPublicKey: HexKey,
): MarmotInnerEvent?
): MantraDialect?
suspend fun addArtifact(
localChatRoom: LocalChatRoom,
@@ -102,14 +101,14 @@ interface MantraRepository {
userPublicKey: HexKey,
visibility: String = DEFAULT_VISIBILITY,
license: String = DEFAULT_LICENSE,
): MarmotInnerEvent?
): MantraArtifact?
suspend fun addArtifactVersion(
localChatRoom: LocalChatRoom,
artifactId: HexKey,
versionLabel: String,
userPublicKey: HexKey,
): MarmotInnerEvent?
): MantraArtifactVersion?
companion object {
const val DEFAULT_VISIBILITY = "private"
@@ -155,7 +154,7 @@ interface MantraRepository {
dialectId: String,
chatRoomId: String,
userPublicKey: HexKey,
): MarmotInnerEvent? = null
): MantraTranslationArtifactVersion? = null
override suspend fun addChapter(
artifactId: String,
@@ -163,7 +162,7 @@ interface MantraRepository {
originalText: String,
chatRoomId: String,
userPublicKey: HexKey,
): MarmotInnerEvent? = null
): MantraChapter? = null
override suspend fun getDialects(chatRoomId: String): List<MantraDialect> = emptyList()
@@ -175,7 +174,7 @@ interface MantraRepository {
country: String,
language: String,
userPublicKey: HexKey,
): MarmotInnerEvent? = null
): MantraDialect? = null
override suspend fun addArtifact(
localChatRoom: LocalChatRoom,
@@ -186,14 +185,14 @@ interface MantraRepository {
userPublicKey: HexKey,
visibility: String,
license: String,
): MarmotInnerEvent? = null
): MantraArtifact? = null
override suspend fun addArtifactVersion(
localChatRoom: LocalChatRoom,
artifactId: HexKey,
versionLabel: String,
userPublicKey: HexKey,
): MarmotInnerEvent? = null
): MantraArtifactVersion? = null
}
}
}

View File

@@ -18,15 +18,14 @@ import androidx.compose.material.icons.filled.Label
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.LocalOffer
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Title
import androidx.compose.material.icons.filled.Translate
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.BottomAppBarDefaults
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -36,6 +35,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -45,6 +45,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -108,14 +110,21 @@ fun AddArtifactScreen(
val nameFieldState = rememberTextFieldState()
val urlFieldState = rememberTextFieldState()
val versionLabelFieldState = rememberTextFieldState("1.0")
val dialectNameFieldState = rememberTextFieldState()
val dialectCountryFieldState = rememberTextFieldState()
val dialectLanguageFieldState = rememberTextFieldState()
// null = "New dialect" (show the create fields); otherwise the id of
// an existing dialect to reuse.
// The id of the group dialect this artifact is written in. Null until
// 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
// M3 gives a FAB no `enabled`, so borrow the disabled colours every
// other button in the app uses rather than inventing a shade here.
val buttonColors = ButtonDefaults.buttonColors()
Scaffold(
topBar = {
TopAppBar(
@@ -132,16 +141,33 @@ fun AddArtifactScreen(
actions = {},
floatingActionButton = {
ExtendedFloatingActionButton(
modifier = if (canAddArtifact) {
Modifier
} else {
// Looking unavailable is not being unavailable:
// without this a screen reader still announces
// a button it is happy to press.
Modifier.semantics { disabled() }
},
containerColor = if (canAddArtifact) {
FloatingActionButtonDefaults.containerColor
} else {
buttonColors.disabledContainerColor
},
contentColor = if (canAddArtifact) {
contentColorFor(FloatingActionButtonDefaults.containerColor)
} else {
buttonColors.disabledContentColor
},
onClick = {
if (!canAddArtifact) return@ExtendedFloatingActionButton
addArtifactViewModel.addArtifact(
localChatRoom = addArtifactUIState.localChatRoom,
nameField = nameFieldState,
urlField = urlFieldState,
versionLabelField = versionLabelFieldState,
existingDialectId = selectedDialectId,
dialectNameField = dialectNameFieldState,
dialectCountryField = dialectCountryFieldState,
dialectLanguageField = dialectLanguageFieldState,
dialectId = selectedDialectId,
onSuccess = { artifactId ->
// Open the newly created artifact, removing this
// add screen from the back stack.
@@ -266,112 +292,24 @@ fun AddArtifactScreen(
Text("Source dialect")
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
addArtifactUIState.dialects.forEach { dialect ->
FilterChip(
selected = selectedDialectId == dialect.id,
onClick = { selectedDialectId = dialect.id },
label = { Text(dialect.name) }
)
if (addArtifactUIState.dialects.isEmpty()) {
Text(
text = "No dialects have been defined in this group yet. Add one from the group's detail screen first.",
style = MaterialTheme.typography.bodySmall
)
} else {
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
addArtifactUIState.dialects.forEach { dialect ->
FilterChip(
selected = selectedDialectId == dialect.id,
onClick = { selectedDialectId = dialect.id },
label = { Text(dialect.name) }
)
}
}
FilterChip(
selected = selectedDialectId == null,
onClick = { selectedDialectId = null },
leadingIcon = {
Icon(
Icons.Default.Add,
contentDescription = "Create a new dialect"
)
},
label = { Text("New dialect") }
)
}
// Only collect new-dialect details when not reusing an existing one.
if (selectedDialectId == null) {
OutlinedTextField(
modifier = Modifier.fillMaxWidth()
.background(BottomAppBarDefaults.containerColor),
state = dialectNameFieldState,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent
),
leadingIcon = {
Icon(
Icons.Default.Title,
contentDescription = "Name of the dialect"
)
},
label = {
Text(
text = "Dialect Name"
)
},
placeholder = {
Text(
text = "eg. Sesotho"
)
},
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth()
.background(BottomAppBarDefaults.containerColor),
state = dialectCountryFieldState,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent
),
leadingIcon = {
Icon(
Icons.Default.Public,
contentDescription = "Country of the dialect"
)
},
label = {
Text(
text = "Country"
)
},
placeholder = {
Text(
text = "eg. Lesotho"
)
},
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth()
.background(BottomAppBarDefaults.containerColor),
state = dialectLanguageFieldState,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent
),
leadingIcon = {
Icon(
Icons.Default.Translate,
contentDescription = "Language of the dialect"
)
},
label = {
Text(
text = "Language"
)
},
placeholder = {
Text(
text = "eg. st"
)
},
)
}
// TODO: Add Visibility

View File

@@ -0,0 +1,348 @@
package press.mantra.compose.ui.composable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Title
import androidx.compose.material.icons.filled.Translate
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.BottomAppBarDefaults
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
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.FrostSigningRoute
import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute
import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
import press.mantra.compose.ui.theme.TorchTheme
import press.mantra.compose.ui.view.model.AddDialectViewModel
import press.mantra.compose.ui.view.state.AddDialectUIState
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
fun AddDialectScreen(
activeUserPublicKey: HexKey,
chatRoomId: String,
relayHint: String?,
initialAddDialectUIState: AddDialectUIState = AddDialectUIState.Loading,
nostrRepository: NostrRepository,
chatRepository: ChatRepository,
frostSigningRepository: FrostSigningRepository,
onNavigateToRouteAndPopUpInclusive: (Route) -> Unit,
onNavigateToRoute: (Route) -> Unit,
) {
val addDialectViewModel: AddDialectViewModel = viewModel(
factory = AddDialectViewModel.factory(
chatRoomId = chatRoomId,
relayHint = relayHint,
initialAddDialectUIState = initialAddDialectUIState,
nostrRepository = nostrRepository,
chatRepository = chatRepository,
activeUserPublicKey = activeUserPublicKey,
frostSigningRepository = frostSigningRepository
)
)
when (val addDialectUIState = addDialectViewModel.addDialectUIState) {
is AddDialectUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
)
Text(
text = addDialectUIState.message,
)
}
}
is AddDialectUIState.Loaded -> {
val nameFieldState = rememberTextFieldState()
val countryFieldState = rememberTextFieldState()
val languageFieldState = rememberTextFieldState()
// M3 gives a FAB no `enabled`, so borrow the disabled colours every
// other button in the app uses rather than inventing a shade here.
val buttonColors = ButtonDefaults.buttonColors()
Scaffold(
topBar = {
TopAppBar(
title = {
addDialectUIState.localChatRoom.RenderChatRoomTitleText()
},
actions = {
}
)
},
bottomBar = {
BottomAppBar(
actions = {},
floatingActionButton = {
ExtendedFloatingActionButton(
modifier = if (addDialectUIState.canSign) {
Modifier
} else {
// Looking unavailable is not being unavailable.
Modifier.semantics { disabled() }
},
containerColor = if (addDialectUIState.canSign) {
FloatingActionButtonDefaults.containerColor
} else {
buttonColors.disabledContainerColor
},
contentColor = if (addDialectUIState.canSign) {
contentColorFor(FloatingActionButtonDefaults.containerColor)
} else {
buttonColors.disabledContentColor
},
onClick = {
if (!addDialectUIState.canSign) return@ExtendedFloatingActionButton
addDialectViewModel.addDialect(
localChatRoom = addDialectUIState.localChatRoom,
nameField = nameFieldState,
countryField = countryFieldState,
languageField = languageFieldState,
onSuccess = { sessionId ->
// Onto the session rather than back to
// the group. Nothing has been created
// yet -- the dialect appears when enough
// members sign -- so landing on the list
// it is not in would read as a failure.
onNavigateToRouteAndPopUpInclusive.invoke(
FrostSigningRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
sessionId = sessionId
)
)
},
onFailure = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Failed Dialect")
)
}
)
}
) {
Icon(
Icons.Default.Add,
contentDescription = "Propose dialect"
)
Text("Propose Dialect")
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxSize()
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Add a dialect the group can translate into")
if (!addDialectUIState.canSign) {
Text(
text = "This group has no shared key, so it cannot sign a " +
"dialect into existence. Run a shared key ceremony first.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
OutlinedTextField(
modifier = Modifier.fillMaxWidth()
.background(BottomAppBarDefaults.containerColor),
state = nameFieldState,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent
),
leadingIcon = {
Icon(
Icons.Default.Title,
contentDescription = "Name of the dialect"
)
},
label = {
Text(
text = "Dialect Name"
)
},
placeholder = {
Text(
text = "eg. Sesotho"
)
},
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth()
.background(BottomAppBarDefaults.containerColor),
state = countryFieldState,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent
),
leadingIcon = {
Icon(
Icons.Default.Public,
contentDescription = "Country of the dialect"
)
},
label = {
Text(
text = "Country"
)
},
placeholder = {
Text(
text = "eg. Lesotho"
)
},
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth()
.background(BottomAppBarDefaults.containerColor),
state = languageFieldState,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent
),
leadingIcon = {
Icon(
Icons.Default.Translate,
contentDescription = "Language of the dialect"
)
},
label = {
Text(
text = "Language"
)
},
placeholder = {
Text(
text = "eg. st"
)
},
)
}
}
}
}
AddDialectUIState.Loading -> {
Column(
modifier = Modifier.fillMaxWidth().padding(
20.dp
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Spacer(
modifier = Modifier.weight(1f)
)
Text(
text = "Add Dialect",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
LoadingDataIndicator(
fillScreen = false
)
Spacer(
modifier = Modifier.weight(2f)
)
}
}
}
LaunchedEffect(true) {
if (initialAddDialectUIState == AddDialectUIState.Loading) {
addDialectViewModel.initiateAddDialect()
}
}
}
@Preview
@Composable
private fun AddDialectScreenPreview() {
TorchTheme {
Surface(
modifier = Modifier.fillMaxSize()
) {
AddDialectScreen(
activeUserPublicKey = "",
chatRoomId = "publicKey",
relayHint = null,
initialAddDialectUIState = AddDialectUIState.Loaded(
localChatRoom = LocalChatRoom(
chatRoom = ChatRoom(
id = "",
userPublicKey = "",
subject = "Message title",
description = "See something. Say somethin",
initialGiftWrapPayloadId = "sdfaer",
mlsGroupState = null
),
)
),
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
onNavigateToRouteAndPopUpInclusive = {},
onNavigateToRoute = {}
)
}
}
}

View File

@@ -18,6 +18,7 @@ import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.filled.LibraryBooks
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.Schema
import androidx.compose.material.icons.filled.Translate
import androidx.compose.material.icons.filled.Unsubscribe
import androidx.compose.material.icons.filled.WaterfallChart
import androidx.compose.material3.ButtonDefaults
@@ -60,6 +61,7 @@ import press.mantra.compose.ui.view.model.ChatRoomDetailViewModel
import press.mantra.compose.ui.view.state.ChatRoomDetailUIState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute
import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute
import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@@ -217,6 +219,72 @@ fun ChatRoomDetailScreen(
HorizontalDivider()
}
item {
// Dialects
Text(
text = "Dialects",
style = MaterialTheme.typography.labelMedium
)
}
if (chatRoomDetailUIState.dialects.isEmpty()) {
item {
Text("No dialects have been defined in this group.")
}
} else {
items(
items = chatRoomDetailUIState.dialects,
key = { dialect -> dialect.id }
) { dialect ->
Card {
ListItem(
leadingContent = {
Icon(
Icons.Default.Translate,
contentDescription = "Dialect"
)
},
headlineContent = {
Text(text = dialect.name)
},
supportingContent = {
Text(text = "${dialect.language} \u00b7 ${dialect.country}")
}
)
}
}
}
item {
TextButton(
onClick = {
onNavigateToRoute.invoke(
AddDialectRoute(
chatRoomId = chatRoomId,
activeUserPublicKey = activeUserPublicKey,
relayHint = relayHint
)
)
}
) {
Icon(
Icons.Default.Translate,
contentDescription = "Add new dialect"
)
Spacer(
modifier = Modifier.width(10.dp)
)
Text("Add Dialect")
}
}
item {
HorizontalDivider()
}
// TODO: Add projects...
item {

View File

@@ -47,6 +47,7 @@ import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute
import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
import press.mantra.compose.ui.theme.TorchTheme
@@ -145,6 +146,16 @@ fun ChatRoomMessagingScreen(
chatRoomId = chatRoomId
)
)
},
onOpenSigning = {
// No session id: a chat row carries none, and the
// screen resolves the room's live one.
onNavigateToRoute.invoke(
FrostSigningRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId
)
)
}
)
}

View File

@@ -0,0 +1,373 @@
package press.mantra.compose.ui.composable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Draw
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.HourglassEmpty
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
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
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
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.TextAlign
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 com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.types.FrostSigningStage
import press.mantra.compose.nostr.nip30303.ArtifactEvent
import press.mantra.compose.nostr.nip30303.ChapterEvent
import press.mantra.compose.nostr.nip30303.DialectEvent
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.FrostSigningRepository
import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar
import press.mantra.compose.ui.theme.TorchTheme
import press.mantra.compose.ui.view.model.FrostSigningViewModel
import press.mantra.compose.ui.view.state.FrostSigningUIState
/**
* One signing session, and the member's decision about it.
*
* A ceremony gets three approval screens because it asks three different
* questions. Signing asks one — sign this or do not — so there is one screen,
* and it has to carry the whole case for the answer: what is being signed, who
* else has agreed, and what the group is still waiting on.
*
* It stays useful after the decision. A session cannot finish until enough
* members take part, so a member who has already signed still needs to see
* whose door to knock on, and the ladder is the only place that says.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FrostSigningScreen(
activeUserPublicKey: HexKey,
chatRoomId: String,
sessionId: String?,
initialFrostSigningUIState: FrostSigningUIState = FrostSigningUIState.Loading,
chatRepository: ChatRepository,
frostSigningRepository: FrostSigningRepository,
onNavigateBack: () -> Unit,
) {
val frostSigningViewModel: FrostSigningViewModel = viewModel(
factory = FrostSigningViewModel.factory(
chatRoomId = chatRoomId,
sessionId = sessionId,
activeUserPublicKey = activeUserPublicKey,
initialFrostSigningUIState = initialFrostSigningUIState,
chatRepository = chatRepository,
frostSigningRepository = frostSigningRepository
)
)
// Nothing loads the room or starts watching the session until this runs.
LaunchedEffect(true) {
if (initialFrostSigningUIState == FrostSigningUIState.Loading) {
frostSigningViewModel.initiate()
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = "Sign with the group's key",
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
when (val state = frostSigningViewModel.frostSigningUIState) {
is FrostSigningUIState.Loading -> Loading(padding)
is FrostSigningUIState.Error -> Column(
modifier = Modifier.fillMaxWidth().padding(padding).padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(50.dp))
Text(text = state.message, textAlign = TextAlign.Center)
}
is FrostSigningUIState.Loaded -> {
// Loaded means the room is loaded, not the session: initiate() sets
// this state and only then starts collecting, so the first emission
// always has a null session. Reading that as "no such session" would
// flash an error on the way in.
val session = state.session ?: return@Scaffold Loading(padding)
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(15.dp)
) {
WhatIsBeingSigned(frostSigningViewModel.proposedEvent(session))
HorizontalDivider()
Text(
text = statusOf(session),
style = MaterialTheme.typography.bodyMedium
)
session.failureReason?.let { reason ->
Text(
text = reason,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
HorizontalDivider()
Text(
text = "Members",
style = MaterialTheme.typography.labelMedium
)
// Named rather than counted, for the same reason the ceremony's
// ladder names people: "1 of 2" does not tell anyone whose door
// to knock on, and a session stalls until somebody knocks.
state.localChatRoom.localParticipants
.distinctBy { it.participant.participantPublicKey }
.forEach { localParticipant ->
val member = localParticipant.participant.participantPublicKey
Card {
ListItem(
leadingContent = {
ProfileAvatar(
publicKey = member,
profile = localParticipant.profile
)
},
trailingContent = {
when {
member in state.signed -> Icon(
Icons.Default.CheckCircle,
contentDescription = "Signed",
tint = MaterialTheme.colorScheme.primary
)
member in state.offeredNonce -> Icon(
Icons.Default.HourglassEmpty,
contentDescription = "Ready to sign"
)
else -> Icon(
Icons.Default.RadioButtonUnchecked,
contentDescription = "Not yet",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
headlineContent = {
Text(
text = localParticipant.profile
?.humanReadableNameOrPubkey()
?: member
)
},
supportingContent = {
Text(
text = when {
member in state.signed -> "Signed their part"
member in state.offeredNonce -> "Ready to sign"
else -> "Has not taken part yet"
}
)
}
)
}
}
if (session.signApprovedAt == null &&
session.stage != FrostSigningStage.COMPLETE &&
session.stage != FrostSigningStage.FAILED
) {
HorizontalDivider()
Text(
text = "Nothing has been published from this device yet. Signing " +
"puts your share behind this event; it cannot be taken back.",
style = MaterialTheme.typography.bodySmall
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Button(
enabled = !frostSigningViewModel.isActionPending.value,
onClick = { frostSigningViewModel.approve(onNavigateBack) }
) {
Icon(Icons.Default.Draw, contentDescription = null)
Spacer(modifier = Modifier.width(10.dp))
Text("Sign")
}
TextButton(
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
),
enabled = !frostSigningViewModel.isActionPending.value,
onClick = { frostSigningViewModel.decline(onNavigateBack) }
) {
Text("Don't sign")
}
}
}
}
}
}
}
}
@Composable
private fun Loading(padding: androidx.compose.foundation.layout.PaddingValues) {
Column(
modifier = Modifier.fillMaxWidth().padding(padding).padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(50.dp))
CircularProgressIndicator()
}
}
/**
* What the group is being asked to put its name to.
*
* Shown as the thing rather than as an event: a member deciding whether to sign
* is deciding about a dialect or an artifact, and "kind 30304" answers a
* question nobody asked. The raw kind stays for anything not recognised, since
* refusing to describe an event is better than describing it wrongly.
*/
@Composable
private fun WhatIsBeingSigned(event: Event?) {
if (event == null) {
Text(
text = "This session's event could not be read, so there is nothing to check " +
"before signing. Don't sign it.",
color = MaterialTheme.colorScheme.error
)
return
}
val (label, detail) = when (event.kind) {
DialectEvent.KIND -> "New dialect" to DialectEvent(
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
).let { dialect ->
listOfNotNull(dialect.name(), dialect.country(), dialect.language())
.joinToString(" · ")
}
ArtifactEvent.KIND -> "New artifact" to event.content
ChapterEvent.KIND -> "New chapter" to ChapterEvent(
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
).name().orEmpty()
else -> "Event of kind ${event.kind}" to event.content
}
Column(verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text(text = label, style = MaterialTheme.typography.labelMedium)
Text(text = detail, style = MaterialTheme.typography.titleMedium)
Text(
text = "Signed by the group, not by you. Once enough members sign, this is " +
"published under the group's shared key.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
private fun statusOf(session: FrostSigningSession): String = when (session.stage) {
FrostSigningStage.COLLECTING_NONCES ->
"Waiting for ${session.threshold} of ${session.participantCount} members to take part."
FrostSigningStage.COLLECTING_PARTIAL_SIGNATURES ->
if (session.isSigner()) {
"You are one of the signers. Waiting on the rest of them."
} else {
"Enough members took part without needing you. Waiting on them to sign."
}
FrostSigningStage.COMPLETE -> "Signed."
FrostSigningStage.FAILED -> "Abandoned. Nothing was signed, and it is safe to ask again."
}
@Preview
@Composable
private fun FrostSigningScreenPreview() {
TorchTheme {
Surface(modifier = Modifier.fillMaxSize()) {
FrostSigningScreen(
activeUserPublicKey = "",
chatRoomId = "chatRoomId",
sessionId = "sessionId",
initialFrostSigningUIState = FrostSigningUIState.Loaded(
localChatRoom = LocalChatRoom(
chatRoom = ChatRoom(
id = "chatRoomId",
userPublicKey = "",
subject = "Group (#admins)",
description = null,
initialGiftWrapPayloadId = "sdfaer",
mlsGroupState = null
)
)
),
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
onNavigateBack = {}
)
}
}
}

View File

@@ -16,6 +16,7 @@ import androidx.navigation.toRoute
import press.mantra.compose.MantraGlobal
import press.mantra.compose.database.repository.DatabaseChatRepository
import press.mantra.compose.database.repository.DatabaseDkgRepository
import press.mantra.compose.database.repository.DatabaseFrostSigningRepository
import press.mantra.compose.database.repository.DatabaseMarmotRepository
import press.mantra.compose.database.repository.DatabaseNostrRepository
import press.mantra.compose.database.repository.DatabaseSearchRepository
@@ -103,6 +104,8 @@ import kotlinx.coroutines.launch
import press.mantra.compose.database.repository.DatabaseMantraRepository
import press.mantra.compose.ui.composable.AddArtifactScreen
import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute
import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute
import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
import press.mantra.compose.ui.composable.navigation.routes.AddChapterRoute
import press.mantra.compose.ui.composable.navigation.routes.AddTranslationRoute
import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute
@@ -112,6 +115,8 @@ import press.mantra.compose.ui.composable.navigation.routes.TranslationChapterRo
import press.mantra.compose.ui.composable.navigation.routes.TranslationArtifactVersionDetailRoute
import press.mantra.compose.ui.composable.ArtifactDetailScreen
import press.mantra.compose.ui.composable.AddChapterScreen
import press.mantra.compose.ui.composable.AddDialectScreen
import press.mantra.compose.ui.composable.FrostSigningScreen
import press.mantra.compose.ui.composable.AddTranslationArtifactVersionScreen
import press.mantra.compose.ui.composable.ChapterDetailScreen
import press.mantra.compose.ui.composable.TranslateChunkScreen
@@ -162,6 +167,13 @@ fun MantraNavHost(
)
}
val databaseFrostSigningRepository = remember {
DatabaseFrostSigningRepository(
database = auxDatabaseManager.auxDatabase,
applicationIOScope
)
}
val databaseMarmotRepository = remember {
DatabaseMarmotRepository(
database = auxDatabaseManager.auxDatabase,
@@ -814,6 +826,46 @@ fun MantraNavHost(
}
)
}
composable<AddDialectRoute> { backStackEntry ->
val route = backStackEntry.toRoute<AddDialectRoute>()
AddDialectScreen(
activeUserPublicKey = route.activeUserPublicKey,
chatRoomId = route.chatRoomId,
relayHint = route.relayHint,
nostrRepository = databaseNostrRepository,
chatRepository = databaseChatRepository,
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 = signingRoute) {
popUpTo<AddDialectRoute> {
inclusive = true
}
}
},
onNavigateToRoute = { actionRoute ->
navController.navigate(
route = actionRoute
)
}
)
}
composable<FrostSigningRoute> { backStackEntry ->
val route = backStackEntry.toRoute<FrostSigningRoute>()
FrostSigningScreen(
activeUserPublicKey = route.activeUserPublicKey,
chatRoomId = route.chatRoomId,
sessionId = route.sessionId,
chatRepository = databaseChatRepository,
frostSigningRepository = databaseFrostSigningRepository,
onNavigateBack = {
navController.popBackStack()
}
)
}
composable<ArtifactDetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<ArtifactDetailRoute>()

View File

@@ -0,0 +1,10 @@
package press.mantra.compose.ui.composable.navigation.routes
import kotlinx.serialization.Serializable
@Serializable
data class AddDialectRoute(
val activeUserPublicKey: String,
val chatRoomId: String, // TODO: have this as a publicKey
val relayHint: String?
): Route()

View File

@@ -0,0 +1,27 @@
package press.mantra.compose.ui.composable.navigation.routes
import kotlinx.serialization.Serializable
/**
* One signing session.
*
* Carries a session id rather than only a room, because a group signs
* repeatedly and can have more than one session open at a time -- unlike a
* ceremony, where "the room's ritual" identifies it.
*/
@Serializable
data class FrostSigningRoute(
val activeUserPublicKey: String,
val chatRoomId: String,
/**
* Null when the caller does not know which session it means.
*
* A transcript line is the main case: chat rows carry no session, and adding
* a column for one feature to a table every message uses is a poor trade for
* a lookup the screen can do. It resolves to the room's live session, which
* is the one a line is talking about in every case but a group running two
* at once.
*/
val sessionId: String? = null
): Route()

View File

@@ -62,10 +62,7 @@ class AddArtifactViewModel(
nameField: TextFieldState,
urlField: TextFieldState,
versionLabelField: TextFieldState,
existingDialectId: HexKey?,
dialectNameField: TextFieldState,
dialectCountryField: TextFieldState,
dialectLanguageField: TextFieldState,
dialectId: HexKey?,
visibility: String = MantraRepository.DEFAULT_VISIBILITY,
license: String = MantraRepository.DEFAULT_LICENSE,
onSuccess: (artifactId: String) -> Unit,
@@ -75,16 +72,10 @@ class AddArtifactViewModel(
val name = nameField.text.toString()
val url = urlField.text.toString()
val versionLabel = versionLabelField.text.toString()
val dialectName = dialectNameField.text.toString()
val dialectCountry = dialectCountryField.text.toString()
val dialectLanguage = dialectLanguageField.text.toString()
// When no existing dialect is selected, the new-dialect fields are required.
val creatingNewDialect = existingDialectId.isNullOrBlank()
val newDialectIncomplete = dialectName.isBlank() || dialectCountry.isBlank() || dialectLanguage.isBlank()
if (name.isBlank() || url.isBlank() || versionLabel.isBlank() ||
(creatingNewDialect && newDialectIncomplete)
) {
// 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
}
@@ -94,21 +85,7 @@ class AddArtifactViewModel(
isActionPending.value = true
viewModelScope.launch(Dispatchers.IO) {
val artifactInnerEvent = runCatching {
// Reuse the selected dialect, or create a new source dialect and
// reference it by id. addArtifact requires a valid dialectId.
val dialectId = if (creatingNewDialect) {
mantraRepository.addDialect(
localChatRoom = localChatRoom,
name = dialectName,
country = dialectCountry,
language = dialectLanguage,
userPublicKey = activeUserPublicKey,
)?.id ?: return@runCatching null
} else {
existingDialectId
}
val artifact = runCatching {
mantraRepository.addArtifact(
localChatRoom = localChatRoom,
name = name,
@@ -123,16 +100,13 @@ class AddArtifactViewModel(
logger.e("Failed to add artifact", error)
}.getOrNull()
if (artifactInnerEvent != null) {
if (artifact != null) {
nameField.clearText()
urlField.clearText()
versionLabelField.clearText()
dialectNameField.clearText()
dialectCountryField.clearText()
dialectLanguageField.clearText()
viewModelScope.launch(Dispatchers.Main) {
onSuccess.invoke(artifactInnerEvent.id)
onSuccess.invoke(artifact.id)
}
} else {
viewModelScope.launch(Dispatchers.Main) {

View File

@@ -0,0 +1,162 @@
package press.mantra.compose.ui.view.model
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.launch
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.nostr.nip30303.DialectEvent
import press.mantra.compose.ui.view.state.AddDialectUIState
class AddDialectViewModel(
val chatRoomId: String,
val activeUserPublicKey: HexKey,
val relayHint: String?,
initialAddDialectUIState: AddDialectUIState,
val nostrRepository: NostrRepository,
val chatRepository: ChatRepository,
val frostSigningRepository: FrostSigningRepository,
): ViewModel() {
var addDialectUIState: AddDialectUIState by mutableStateOf(initialAddDialectUIState)
private set
private val logger = Logger.withTag(TAG)
val isActionPending: MutableState<Boolean> = mutableStateOf(false)
/**
* Whether this group holds a key it can sign with.
*
* Read before the form is offered: a group without one cannot make a dialect
* this way at all, and a button that always fails is worse than no button.
*/
suspend fun canSign(): Boolean = frostSigningRepository.canSign(chatRoomId)
fun initiateAddDialect() {
logger.d("compressed (most likely chat room): $chatRoomId")
viewModelScope.launch(Dispatchers.IO) {
val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
addDialectUIState = if (localChatRoom == null) {
AddDialectUIState.Error("Couldn't find the chat room")
} else {
AddDialectUIState.Loaded(
localChatRoom = localChatRoom,
canSign = frostSigningRepository.canSign(chatRoomId),
)
}
}
}
/**
* Asks the group to sign a new dialect into existence.
*
* The dialect is not created here and does not exist yet. What goes out is a
* proposal to sign it, and the dialect 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.
*/
fun addDialect(
localChatRoom: LocalChatRoom,
nameField: TextFieldState,
countryField: TextFieldState,
languageField: TextFieldState,
onSuccess: (sessionId: String) -> Unit,
onFailure: () -> Unit
) {
val name = nameField.text.toString()
val country = countryField.text.toString()
val language = languageField.text.toString()
if (name.isBlank() || country.isBlank() || language.isBlank()) {
onFailure.invoke()
return
}
// Guard against double submits from repeated FAB taps.
if (isActionPending.value) return
isActionPending.value = true
viewModelScope.launch(Dispatchers.IO) {
val dialectEventTemplate = DialectEvent.build(
name = name,
country = country,
language = language,
)
val session = runCatching {
frostSigningRepository.proposeSigning(
localChatRoom = localChatRoom,
userPublicKey = activeUserPublicKey,
kind = dialectEventTemplate.kind,
tags = dialectEventTemplate.tags,
content = dialectEventTemplate.content,
)
}.onFailure { error ->
logger.e("Failed to propose a dialect for signing", error)
}.getOrNull()
if (session != null) {
nameField.clearText()
countryField.clearText()
languageField.clearText()
viewModelScope.launch(Dispatchers.Main) {
onSuccess.invoke(session.id)
}
} else {
viewModelScope.launch(Dispatchers.Main) {
onFailure.invoke()
}
}
isActionPending.value = false
}
}
companion object {
private const val TAG = "AddDialectViewModel"
fun factory(
activeUserPublicKey: HexKey,
chatRoomId: String,
relayHint: String?,
initialAddDialectUIState: AddDialectUIState = AddDialectUIState.Loading,
nostrRepository: NostrRepository,
chatRepository: ChatRepository,
frostSigningRepository: FrostSigningRepository
): ViewModelProvider.Factory = viewModelFactory {
initializer {
AddDialectViewModel(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
relayHint = relayHint,
initialAddDialectUIState = initialAddDialectUIState,
nostrRepository = nostrRepository,
chatRepository = chatRepository,
frostSigningRepository = frostSigningRepository
)
}
}
}
}

View File

@@ -25,6 +25,8 @@ import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.CallMerge
import androidx.compose.material.icons.filled.FactCheck
import androidx.compose.material.icons.filled.Draw
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.PanTool
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.Upload
@@ -63,11 +65,13 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import press.mantra.compose.database.model.ChatMessage
import press.mantra.compose.database.model.NegentropySynchronizeRequest
import press.mantra.compose.database.model.Participant
import press.mantra.compose.database.model.intermdiate.LocalChatMessage
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.types.SynchronizationFilter
import press.mantra.compose.extensions.shortened
import press.mantra.compose.extensions.toFormattedTimeAndDateString
import press.mantra.compose.nostr.Nip17Filters
import press.mantra.compose.nostr.Relays
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.NostrRepository
@@ -75,10 +79,8 @@ import press.mantra.compose.ui.composable.widgets.profile.ProfileColor
import press.mantra.compose.ui.view.state.ChatMessageListUIState
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import press.mantra.compose.database.model.Participant
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -181,18 +183,13 @@ class ChatMessageListViewModel(
val chatMessageRelayListEvent = chatRepository.getChatMessageRelayForPublicKey(recipients.participant.participantPublicKey)
val relayAndSynchronizationFilter = if (chatMessageRelayListEvent != null) {
// Sync messages from this relay...
// Opening a conversation refreshes our own inbox: this used to
// p-tag the peer and read their relays, which is where their mail
// is kept, not ours. See Nip17Filters for why a per-conversation
// filter is not a thing that can be written.
Pair(
chatMessageRelayListEvent.relays(),
SynchronizationFilter(
kinds = arrayOf(
GiftWrapEvent.KIND,
),
tags = mapOf(
Pair("p", listOf(recipients.participant.participantPublicKey))
),
limit = 50
)
Relays.DefaultDMRelayList,
Nip17Filters.inbox(localChatRoom.chatRoom.userPublicKey),
)
} else {
@@ -224,7 +221,7 @@ class ChatMessageListViewModel(
purpose = if (isReceiverChatMessageRelayListMissing.value) {
"chat-message-relays"
} else {
"sent-messages"
"chat"
},
synchronizationFilter = relayAndSynchronizationFilter.second,
relayURL = normalizedRelayUrl.url,
@@ -275,7 +272,7 @@ class ChatMessageListViewModel(
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun RenderMessages(onOpenSharedKey: () -> Unit) {
fun RenderMessages(onOpenSharedKey: () -> Unit, onOpenSigning: () -> Unit) {
Column(
modifier = Modifier.fillMaxWidth(),
@@ -330,8 +327,10 @@ class ChatMessageListViewModel(
val answeredRequests = chatRoomDetailMessageListUIState
.chatMessageList
.mapNotNull { request ->
val published = ChatMessage
.DKG_REQUEST_FULFILMENTS[request.chatMessage.messageType]
val published = (
ChatMessage.DKG_REQUEST_FULFILMENTS +
ChatMessage.FROST_REQUEST_FULFILMENTS
)[request.chatMessage.messageType]
?: return@mapNotNull null
val done = chatRoomDetailMessageListUIState.chatMessageList.any {
@@ -421,6 +420,20 @@ class ChatMessageListViewModel(
return@items
}
// Signing lines are the same kind of thing and get
// the same treatment -- nobody said them either --
// but they lead somewhere else, because what a
// reader needs from one is the event being signed
// rather than the state of the key.
if (localChatMessage.chatMessage.messageType in ChatMessage.FROST_TYPES) {
RitualNotice(
localChatMessage = localChatMessage,
isAnswered = localChatMessage.chatMessage.id in answeredRequests,
onClick = onOpenSigning
)
return@items
}
BoxWithConstraints(
modifier = Modifier.fillMaxWidth()
) {
@@ -709,6 +722,15 @@ private fun RitualNotice(
ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_1 -> Icons.Default.Upload
ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_2 -> Icons.Default.FactCheck
ChatMessage.TYPE_FROST_STARTED -> Icons.Default.Draw
ChatMessage.TYPE_FROST_NONCE -> Icons.Default.Upload
ChatMessage.TYPE_FROST_SIGNER_SET -> Icons.Default.Groups
ChatMessage.TYPE_FROST_PARTIAL_SIGNATURE -> Icons.Default.Draw
ChatMessage.TYPE_FROST_SIGNATURE -> Icons.Default.WorkspacePremium
ChatMessage.TYPE_FROST_COMPLETE -> Icons.Default.CheckCircle
ChatMessage.TYPE_FROST_FAILED -> Icons.Default.ErrorOutline
ChatMessage.TYPE_FROST_APPROVAL_NEEDED -> Icons.Default.Draw
else -> Icons.Default.PanTool
}
@@ -717,10 +739,15 @@ private fun RitualNotice(
// quiet; these are not.
// An answered request is history, not a summons: it keeps its stage's icon so
// the step is still recognisable, but drops the colour and the call to action.
val isRequest = chatMessage.messageType in ChatMessage.DKG_REQUEST_TYPES && !isAnswered
val isRequest = (
chatMessage.messageType in ChatMessage.DKG_REQUEST_TYPES ||
chatMessage.messageType in ChatMessage.FROST_REQUEST_TYPES
) && !isAnswered
val tint = when {
chatMessage.messageType == ChatMessage.TYPE_DKG_FAILED -> MaterialTheme.colorScheme.error
chatMessage.messageType == ChatMessage.TYPE_DKG_FAILED ||
chatMessage.messageType == ChatMessage.TYPE_FROST_FAILED ->
MaterialTheme.colorScheme.error
isRequest -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
@@ -750,7 +777,9 @@ private fun RitualNotice(
// from the joined profile rather than written into the content,
// so it follows a rename and is not stuck on the "LOADING..."
// placeholder a member is given the moment they are first seen.
if (chatMessage.messageType in ChatMessage.DKG_AUTHORED_TYPES) {
if (chatMessage.messageType in ChatMessage.DKG_AUTHORED_TYPES ||
chatMessage.messageType in ChatMessage.FROST_AUTHORED_TYPES
) {
withStyle(
SpanStyle(color = ProfileColor.fromPublicKey(chatMessage.senderPublicKey))
) {

View File

@@ -55,6 +55,7 @@ class ChatRoomDetailViewModel(
ChatRoomDetailUIState.Loaded(
localChatRoom = localChatRoom,
artifacts = mantraRepository.getArtifacts(chatRoomId),
dialects = mantraRepository.getDialects(chatRoomId),
)
}
}

View File

@@ -27,13 +27,13 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import press.mantra.compose.database.model.NegentropySynchronizeRequest
import press.mantra.compose.database.model.types.SynchronizationFilter
import press.mantra.compose.nostr.Nip17Filters
import press.mantra.compose.nostr.Relays
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.ui.view.state.ChatRoomListUIState
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.launch
@@ -72,15 +72,7 @@ class ChatRoomListViewModel(
logger.d("scheduleSynchronization")
viewModelScope.launch(Dispatchers.IO) {
// Sync Notifications... might want to also run this in the background
val chatRequestFilter = SynchronizationFilter(
kinds = arrayOf(
GiftWrapEvent.KIND,
),
tags = mapOf(
Pair("p", listOf(publicKey))
),
limit = 50
)
val chatRequestFilter = Nip17Filters.inbox(publicKey)
nostrRepository.queueNegentropySynchronizeRequest(
Relays.DefaultDMRelayList.shuffled().map { normalizedRelayUrl ->
NegentropySynchronizeRequest(

View File

@@ -251,7 +251,8 @@ class DkgRitualViewModel(
if (isActionPending.value) return
val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return
val thresholdPublicKey = loaded.session?.thresholdPublicKey ?: return
val session = loaded.session ?: return
val thresholdPublicKey = session.thresholdPublicKey ?: return
val nostrPrivateKey = activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey()
if (nostrPrivateKey == null) {
@@ -383,6 +384,28 @@ class DkgRitualViewModel(
logger.e("Failed to add members to admin group $groupId", it)
}.getOrElse { addable.map { (publicKey, _) -> publicKey } }
// The room's first message: which ceremony's key it signs with, and
// the path its id was derived at. What a signer reaches for when a
// signing request arrives and it has to pick one of its shares.
//
// After the members are added rather than before, which is the only
// order that works: adding them commits a new epoch, and MLS will not
// let a member read what was encrypted before the epoch they joined
// at. Announced first, the announcement would reach nobody but its
// author. It is still the room's first *application* message -- what
// comes before it is handshake.
//
// A member invited later still misses it for the same reason, and is
// left where every member was before this event existed: falling back
// to FrostSigningManager.completedKey's rederivation. Re-announcing
// on invite is the fix, and is cheap because a repeat announcement
// folds away rather than accumulating.
dkgRepository.announceGroupKeyState(
chatRoomId = localChatRoom.chatRoom.id,
userPublicKey = activeUserPublicKey,
session = session
)
isActionPending.value = false
if (notAdded.isNotEmpty()) {

View File

@@ -0,0 +1,156 @@
package press.mantra.compose.ui.view.model
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.nostr.frost.FrostSigningEvents
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.FrostSigningRepository
import press.mantra.compose.ui.view.state.FrostSigningUIState
class FrostSigningViewModel(
val chatRoomId: String,
val sessionId: String?,
val activeUserPublicKey: HexKey,
initialFrostSigningUIState: FrostSigningUIState,
val chatRepository: ChatRepository,
val frostSigningRepository: FrostSigningRepository,
): ViewModel() {
var frostSigningUIState: FrostSigningUIState by mutableStateOf(initialFrostSigningUIState)
private set
private val logger = Logger.withTag(TAG)
val isActionPending: MutableState<Boolean> = mutableStateOf(false)
/**
* Loads the room, then watches the session for as long as the screen lives.
*
* A session moves on messages arriving from other members, so a screen that
* read it once would sit still while the rest of the group signed around it.
*/
fun initiate() {
viewModelScope.launch(Dispatchers.IO) {
val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
if (localChatRoom == null) {
frostSigningUIState = FrostSigningUIState.Error("Couldn't find the group")
return@launch
}
// A transcript line knows its room but not its session, so resolve one
// before watching anything.
val id = sessionId
?: frostSigningRepository.liveSessionForChatRoom(chatRoomId)?.id
if (id == null) {
frostSigningUIState =
FrostSigningUIState.Error("This group is not signing anything right now.")
return@launch
}
frostSigningUIState = FrostSigningUIState.Loaded(localChatRoom = localChatRoom)
combine(
frostSigningRepository.observeSessionById(id),
frostSigningRepository.observeMessages(id)
) { session, messages -> session to messages }
.collect { (session, messages) ->
frostSigningUIState = FrostSigningUIState.Loaded(
localChatRoom = localChatRoom,
session = session,
offeredNonce = messages
.filter { it.kind == FrostSigningEvents.NONCE }
.map { it.signerPublicKey }
.toSet(),
signed = messages
.filter { it.kind == FrostSigningEvents.PARTIAL_SIGNATURE }
.map { it.signerPublicKey }
.toSet()
)
}
}
}
/** The event the group is being asked to sign, for showing it before they agree. */
fun proposedEvent(session: FrostSigningSession): Event? =
Event.fromJsonOrNull(session.unsignedEventJson)
fun signedEvent(session: FrostSigningSession): Event? =
frostSigningRepository.signedEvent(session)
fun approve(onDone: () -> Unit) {
val state = frostSigningUIState as? FrostSigningUIState.Loaded ?: return
val sessionId = state.session?.id ?: return
if (isActionPending.value) return
isActionPending.value = true
viewModelScope.launch(Dispatchers.IO) {
frostSigningRepository.approve(
localChatRoom = state.localChatRoom,
sessionId = sessionId
)
isActionPending.value = false
viewModelScope.launch(Dispatchers.Main) { onDone.invoke() }
}
}
fun decline(onDone: () -> Unit) {
val state = frostSigningUIState as? FrostSigningUIState.Loaded ?: return
val sessionId = state.session?.id ?: return
if (isActionPending.value) return
isActionPending.value = true
viewModelScope.launch(Dispatchers.IO) {
frostSigningRepository.decline(
localChatRoom = state.localChatRoom,
sessionId = sessionId
)
isActionPending.value = false
viewModelScope.launch(Dispatchers.Main) { onDone.invoke() }
}
}
companion object {
private const val TAG = "FrostSigningViewModel"
fun factory(
chatRoomId: String,
sessionId: String?,
activeUserPublicKey: HexKey,
initialFrostSigningUIState: FrostSigningUIState = FrostSigningUIState.Loading,
chatRepository: ChatRepository,
frostSigningRepository: FrostSigningRepository,
): ViewModelProvider.Factory = viewModelFactory {
initializer {
FrostSigningViewModel(
chatRoomId = chatRoomId,
sessionId = sessionId,
activeUserPublicKey = activeUserPublicKey,
initialFrostSigningUIState = initialFrostSigningUIState,
chatRepository = chatRepository,
frostSigningRepository = frostSigningRepository
)
}
}
}
}

View File

@@ -0,0 +1,22 @@
package press.mantra.compose.ui.view.state
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
sealed interface AddDialectUIState {
data class Loaded(
val localChatRoom: LocalChatRoom,
/**
* Whether the group holds a shared key. A dialect is signed into
* existence now rather than submitted, so a group without one cannot
* make one here at all.
*/
val canSign: Boolean = false,
): AddDialectUIState
data class Error(
val message: String
): AddDialectUIState
data object Loading: AddDialectUIState
}

View File

@@ -1,12 +1,14 @@
package press.mantra.compose.ui.view.state
import press.mantra.compose.database.model.MantraArtifact
import press.mantra.compose.database.model.MantraDialect
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
sealed interface ChatRoomDetailUIState {
data class Loaded(
val localChatRoom: LocalChatRoom,
val artifacts: List<MantraArtifact> = emptyList(),
val dialects: List<MantraDialect> = emptyList(),
): ChatRoomDetailUIState
data class Error(

View File

@@ -0,0 +1,26 @@
package press.mantra.compose.ui.view.state
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
sealed interface FrostSigningUIState {
data class Loaded(
val localChatRoom: LocalChatRoom,
/** Null on the first emission, before the session has been collected. */
val session: FrostSigningSession? = null,
/** Who has offered a nonce, so the screen can name who it is waiting on. */
val offeredNonce: Set<HexKey> = emptySet(),
/** Who has signed their part. */
val signed: Set<HexKey> = emptySet(),
): FrostSigningUIState
data class Error(
val message: String
): FrostSigningUIState
data object Loading: FrostSigningUIState
}

View File

@@ -0,0 +1,141 @@
package press.mantra.compose.database.model
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
/**
* Who can open a gift wrap, and what becomes of everyone else's.
*
* NIP-59 encrypts a wrap under ECDH(ephemeralPriv, recipientPub), and
* [GiftWrapEvent.create] discards that ephemeral key before it returns. The
* recipient named in the `p` tag is therefore the only party who can ever unseal
* one -- the sender included. Reading a wrap that is not ours is not a decryption
* that might fail, it is one that cannot be attempted, and the code that tried
* anyway threw `IllegalStateException: Invalid Mac` out of Nip44 and took the
* enclosing Room transaction down with it, so the event was rolled back and
* re-fetched on every later sync.
*
* These run real secp256k1 rather than recorded fixtures on purpose: the property
* under test is about the key agreement itself, and a fixture would only prove the
* fixture still parses.
*/
class GiftWrapMessageTest {
private val us = KeyPair()
private val peer = KeyPair()
private val stranger = KeyPair()
/**
* A real wrap, sealed the way DatabaseChatRepository seals one and then mapped
* into the entity the way NostrEvent.toGiftWrapMessageWithReceiverPTag maps it.
*/
private fun wrap(
sender: KeyPair,
recipient: KeyPair,
): GiftWrapMessage {
val signer = NostrSignerSync(sender)
val seal = signer.signNormal<SealedRumorEvent>(
createdAt = SEALED_AT,
kind = SealedRumorEvent.KIND,
tags = emptyArray(),
content = signer.nip44Encrypt(
plaintext = """{"kind":14,"content":"dumela"}""",
toPublicKey = recipient.pubKey.toHexKey(),
),
)
val giftWrap = GiftWrapEvent.create(
event = seal,
recipientPubKey = recipient.pubKey.toHexKey(),
createdAt = WRAPPED_AT,
)
return GiftWrapMessage(
id = giftWrap.id,
publicKey = giftWrap.pubKey,
receiverPublicKey = recipient.pubKey.toHexKey(),
receiverRelayHit = null,
content = giftWrap.content,
signature = giftWrap.sig,
nostrEventId = giftWrap.id,
createdAt = Instant.fromEpochSeconds(giftWrap.createdAt),
)
}
@Test
fun `a wrap addressed to us gives up its seal`() = runTest {
val seal = wrap(sender = peer, recipient = us).decryptGiftWrapSeal(us)
assertNotNull(seal)
// Only the wrap was anonymous. The seal inside carries the real sender, which
// is what lets the impersonation check downstream compare it to the payload.
assertEquals(peer.pubKey.toHexKey(), seal.publicKey)
}
@Test
fun `someone else's mail comes back null rather than throwing`() = runTest {
// The event from the crash report: a wrap between two other people, pulled in
// by a filter that named a peer where it should have named us.
val theirs = wrap(sender = stranger, recipient = peer)
assertNull(theirs.decryptGiftWrapSeal(us))
}
@Test
fun `not even the sender can reopen what they sent`() = runTest {
// What the old branch called "a message we may have sent" and tried to decrypt
// regardless. The key that encrypted it no longer exists anywhere; holding the
// sending identity buys nothing back.
val ours = wrap(sender = us, recipient = peer)
assertNull(ours.decryptGiftWrapSeal(us))
}
@Test
fun `isAddressedTo reads the p tag whatever case it arrived in`() {
val message = wrap(sender = peer, recipient = us)
assertTrue(message.isAddressedTo(us))
assertFalse(message.isAddressedTo(peer))
assertTrue(
message
.copy(receiverPublicKey = message.receiverPublicKey.uppercase())
.isAddressedTo(us),
)
}
@Test
fun `isAddressedTo answers exactly what unsealing would`() = runTest {
// NostrDao skips indexing on isAddressedTo and throws GiftWrapUnsealException on
// a null seal. Should those two ever disagree, one path or the other is wrong:
// either mail we can open is skipped, or the transaction rolls back again.
listOf(
wrap(sender = peer, recipient = us),
wrap(sender = stranger, recipient = peer),
wrap(sender = us, recipient = peer),
).forEach { message ->
assertEquals(
message.isAddressedTo(us),
message.decryptGiftWrapSeal(us) != null,
"isAddressedTo and decryptGiftWrapSeal disagree on ${message.id}",
)
}
}
private companion object {
const val SEALED_AT = 1_700_000_000L
const val WRAPPED_AT = 1_700_000_100L
}
}

View File

@@ -0,0 +1,134 @@
package press.mantra.compose.database.model
import com.vitorpamplona.quartz.marmot.mls.messages.CommitResult
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.time.Instant
/**
* Where a commit's bytes land when the row that records it is written.
*
* `MarmotCommitResult` carries quartz's `CommitResult` payload fields verbatim --
* `commitBytes`, `welcomeBytes`, `groupInfoBytes`, `framedCommitBytes`,
* `preCommitExporterSecret`, the same names and all of them `ByteArray`. A value
* taken from the wrong field of the right object therefore typechecks, and
* `framedCommitBytes = commitResult.preCommitExporterSecret` reached the database
* that way and sat there unnoticed: the column documented to hold a broadcastable
* `MlsMessage(PublicMessage(FramedContent(commit)))` envelope held 32 bytes of the
* group's pre-commit exporter secret instead.
*
* Nothing caught it because nothing read the column. The bytes that reached the
* relay come off the in-memory `CommitResult`, so the wire stayed correct while the
* record of it did not, and the row is written precisely so that the
* acknowledgement path in `DatabaseNostrRepository` can pick work back up later. A
* rebroadcast reading `framedCommitBytes` would have published noise the group
* decrypts, fails to parse, and drops -- silent, which is this subsystem's
* characteristic failure.
*
* So the routing is pinned here. Every payload gets a distinct, self-identifying
* value: a field that ends up in the wrong column names both halves of the mistake
* when it fails, rather than comparing equal by accident.
*/
class MarmotCommitResultMappingTest {
private val commitBytes = "raw-commit".encodeToByteArray()
private val framedCommitBytes = "framed-commit-envelope".encodeToByteArray()
private val welcomeBytes = "welcome".encodeToByteArray()
private val groupInfoBytes = "group-info".encodeToByteArray()
/** Stands in for `MLS-Exporter("marmot", "group-event", 32)` at the pre-commit epoch. */
private val preCommitExporterSecret = ByteArray(32) { 0x5E }
private val commitEventId = "a".repeat(64)
private val chatRoomId = "b".repeat(64)
private val userPublicKey = "c".repeat(64)
private val peerKeyPackageEventId = "d".repeat(64)
private val createdAt = Instant.fromEpochSeconds(1_700_000_000)
private fun commitResult(
framedCommitBytes: ByteArray = this.framedCommitBytes,
preCommitExporterSecret: ByteArray = this.preCommitExporterSecret,
) = CommitResult(
commitBytes = commitBytes,
welcomeBytes = welcomeBytes,
groupInfoBytes = groupInfoBytes,
framedCommitBytes = framedCommitBytes,
preCommitExporterSecret = preCommitExporterSecret,
)
private fun map(commitResult: CommitResult) = MarmotCommitResult.from(
commitEventId = commitEventId,
commitResult = commitResult,
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
peerKeyPackageEventId = peerKeyPackageEventId,
isOneMemberInitialGroupCreation = false,
createdAt = createdAt,
)
@Test
fun `every payload field lands in its own column`() {
val row = map(commitResult())
assertContentEquals(commitBytes, row.commitBytes, "commitBytes")
assertContentEquals(welcomeBytes, row.welcomeBytes, "welcomeBytes")
assertContentEquals(groupInfoBytes, row.groupInfoBytes, "groupInfoBytes")
assertContentEquals(framedCommitBytes, row.framedCommitBytes, "framedCommitBytes")
assertContentEquals(
preCommitExporterSecret,
row.preCommitExporterSecret,
"preCommitExporterSecret"
)
}
@Test
fun `the framed commit column never holds the exporter secret`() {
// The regression. Stated as the invariant rather than as an equality check,
// so it keeps holding for a CommitResult this test did not anticipate.
val row = map(commitResult())
assertFalse(
row.framedCommitBytes.contentEquals(row.preCommitExporterSecret),
"the group's exporter secret was stored as the framed commit"
)
}
@Test
fun `a CommitResult that never framed its commit still stores a commit`() {
// quartz defaults framedCommitBytes to commitBytes, and the entity repeats that
// default. Whichever of the two a row ends up with, it must be a commit -- the
// fallback must not quietly become the secret either.
val unframed = CommitResult(
commitBytes = commitBytes,
welcomeBytes = welcomeBytes,
groupInfoBytes = groupInfoBytes,
preCommitExporterSecret = preCommitExporterSecret,
)
val row = map(unframed)
assertContentEquals(commitBytes, row.framedCommitBytes)
assertFalse(
row.framedCommitBytes.contentEquals(row.preCommitExporterSecret),
"the group's exporter secret was stored as the framed commit"
)
}
@Test
fun `the bookkeeping the acknowledgement path reads is carried through`() {
// DatabaseNostrRepository finds this row by the commit event's id and delivers the
// welcome using chatRoomId, userPublicKey and peerKeyPackageEventId. All four are
// supplied by the caller rather than the CommitResult, so they are checked here to
// keep the argument order of `from` honest -- every one of them is a 64-char hex
// string, and swapping two would otherwise typecheck as silently as the bug did.
val row = map(commitResult())
assertEquals(commitEventId, row.id)
assertEquals(chatRoomId, row.chatRoomId)
assertEquals(userPublicKey, row.userPublicKey)
assertEquals(peerKeyPackageEventId, row.peerKeyPackageEventId)
assertEquals(createdAt, row.createdAt)
assertFalse(row.isOneMemberInitialGroupCreation)
}
}

View File

@@ -0,0 +1,174 @@
package press.mantra.compose.database.model
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import kotlin.test.Test
import kotlin.test.assertEquals
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
import press.mantra.compose.nostr.nip30303.DialectEvent
import press.mantra.compose.nostr.nip30303.SubmissionEvent
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag
/**
* The row on disk and the payload on the wire have to be the same event.
*
* `MantraDao` writes an entity whose id comes from `MantraX.fromXEventTemplate`,
* and separately builds the rumor it submits with `rumorOf`, which hashes the
* template itself. Both are supposed to produce one id. Nothing checks that they
* do, and nothing would notice if they stopped:
*
* - the submission would carry a `payloadId` naming an event nobody has,
* - `MarmotInnerEvent.payloadEventId` would stop matching the row it carries,
* so `deleteByPayloadEventId` would silently un-queue nothing and superseded
* translations would go out anyway,
* - and every receiver would create a *second* row rather than converging on
* the sender's, because entity ids are content hashes and the two sides would
* be hashing different things.
*
* All of that is silent. The ids are opaque hex either way.
*/
class RumorIdAgreementTest {
private val author = "a".repeat(64)
private val chatRoomId = "room"
private val other = "b".repeat(64)
/** Exactly what `MantraDao.rumorOf` does, and it must stay exactly that. */
private fun rumorIdOf(template: EventTemplate<*>): String = EventHasher.hashId(
pubKey = author,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content
)
@Test
fun `a dialect's row and its rumor agree`() {
val template = DialectEvent.build(name = "Sesotho", country = "Lesotho", language = "st")
val entity = MantraDialect.fromDialectEventTemplate(
dialectEventTemplate = template,
chatRoomId = chatRoomId,
userPublicKey = author
)
assertEquals(rumorIdOf(template), entity?.id)
}
@Test
fun `an artifact's row and its rumor agree`() {
val template = ArtifactEvent.build(
name = "In Detention",
url = "example.com",
visibility = "private",
license = "cc",
dialectId = other
)
val entity = MantraArtifact.fromArtifactEventTemplate(
artifactEventTemplate = template,
chatRoomId = chatRoomId,
userPublicKey = author
)
assertEquals(rumorIdOf(template), entity?.id)
}
@Test
fun `an artifact version's row and its rumor agree`() {
val template = ArtifactVersionEvent.build(content = "1.0") {
addUnique(ArtifactIdTag.assemble(other))
}
val entity = MantraArtifactVersion.fromArtifactVersionEventTemplate(
artifactVersionEventTemplate = template,
chatRoomId = chatRoomId,
userPublicKey = author
)
assertEquals(rumorIdOf(template), entity?.id)
}
@Test
fun `a chapter's and a chunk's rows agree with their rumors`() {
val chapter = ChapterEvent.build(
artifactVersionId = other,
name = "Chapter 1",
originalText = "some text",
index = 0,
wordCount = 2,
characterCount = 9
)
val chunk = ChunkEvent.build(
chapterId = other,
text = "some text",
index = 0,
wordCount = 2,
characterCount = 9
)
assertEquals(
rumorIdOf(chapter),
MantraChapter.fromChapterEventTemplate(chapter, chatRoomId, author)?.id
)
assertEquals(
rumorIdOf(chunk),
MantraChunk.fromChunkEventTemplate(chunk, chatRoomId, author)?.id
)
}
@Test
fun `a translation version's row and its rumor agree`() {
val template = TranslationArtifactVersionEvent.build(
artifactVersionId = other,
dialectId = other,
name = "Sesotho",
visibility = "private",
license = "cc"
)
val entity = MantraTranslationArtifactVersion.fromTranslationArtifactVersionEventTemplate(
translationArtifactVersionEventTemplate = template,
chatRoomId = chatRoomId,
userPublicKey = author
)
assertEquals(rumorIdOf(template), entity?.id)
}
@Test
fun `the submission names the id the row was written under`() {
// The end of the chain the rest of this file checks a link of: what a
// receiver reads out of the envelope has to be the id the sender stored.
val template = DialectEvent.build(name = "Sesotho", country = "Lesotho", language = "st")
val entity = MantraDialect.fromDialectEventTemplate(template, chatRoomId, author)
val payload = Event(
id = rumorIdOf(template),
pubKey = author,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content,
sig = ""
)
val submission = SubmissionEvent.build(payload = payload)
val readBack = SubmissionEvent(
id = "f".repeat(64),
pubKey = author,
createdAt = submission.createdAt,
tags = submission.tags,
content = submission.content,
sig = ""
)
assertEquals(entity?.id, readBack.payloadId())
assertEquals(entity?.id, readBack.payload()?.id)
assertEquals(DialectEvent.KIND, readBack.payloadKind())
}
}

View File

@@ -0,0 +1,278 @@
package press.mantra.compose.managers
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
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 fr.acinq.secp256k1.Hex
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.extensions.toHex
import press.mantra.compose.nostr.frost.FrostSigningEvents
/**
* The two rounds a signing session runs, against real FROST.
*
* `FrostSigningManager` spreads these steps across arriving messages, several
* devices and a database, none of which a unit test can stand up. What it can
* do is run the same calls in the same order with the same arguments and check
* that what comes out is a signature nostr will accept — which is the part
* that was written from reading the library rather than from a working example,
* and so the part most likely to be subtly wrong.
*
* A signature that verifies is the whole contract: if these calls are wired up
* incorrectly the aggregate simply fails to verify, silently, on every device.
*/
class FrostSigningRoundTest {
private val participants = 3
private val threshold = 2
/** Stands in for a completed ceremony. A trusted dealer is fine here: the test is about signing. */
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()
/** The 32 bytes actually signed — a nostr event id, exactly as the manager computes it. */
private fun eventId(content: String): String = EventHasher.hashId(
pubKey = groupPubKey,
createdAt = 1_700_000_000L,
kind = 1,
tags = arrayOf(),
content = content
)
/**
* One signer's half of the protocol, in the manager's order: regenerate the
* nonce from stored randomness, then sign once the set is known.
*/
private fun nonceOf(signerId: Int, message: ByteVector, random: String): Pair<SecretNonce, IndividualNonce> =
SecretNonce.generate(
sessionRandom = ByteVector32(random),
secretShare = keyMaterial.secretShares[signerId],
publicShare = keyMaterial.publicShares[signerId],
tweakedThresholdPublicKey = tweakCache.tweakedPublicKey,
message = message,
extraInput = null
)
private fun sessionFor(signerIds: List<Int>, nonces: List<IndividualNonce>, message: ByteVector): Session {
val aggregated = IndividualNonce.aggregate(nonces).right!!
return Session.create(
aggregatedNonce = aggregated,
signerIds = signerIds.map { it.toUInt() },
signerPublicShares = signerIds.map { keyMaterial.publicShares[it] },
nParticipants = participants,
threshold = threshold,
tweakCache = tweakCache,
message = message
)
}
@Test
fun `a threshold of signers produces a signature nostr accepts`() {
val id = eventId("the group agrees")
val message = ByteVector(id.hexToByteArray())
// Two of the three sign, which is the point of a 2-of-3 key.
val signerIds = listOf(0, 1)
val nonces = signerIds.map { nonceOf(it, message, "a".repeat(63) + "${it + 1}") }
val session = sessionFor(signerIds, nonces.map { it.second }, message)
val partials = signerIds.mapIndexed { position, signerId ->
session.sign(nonces[position].first, keyMaterial.secretShares[signerId], signerId.toUInt()).right!!
}
val signature = session.aggregateSigs(partials).right!!
assertTrue(
Nip01Crypto.verify(
signature = signature.toByteArray(),
hash = id.hexToByteArray(),
pubKey = groupPubKey.hexToByteArray()
),
"the aggregated signature must verify against the group's x-only key"
)
}
@Test
fun `a different pair of signers signs the same event just as well`() {
val id = eventId("the group agrees")
val message = ByteVector(id.hexToByteArray())
// Whoever happens to be available. The coordinator picks; the signature
// that comes out must not depend on which t it picked.
val signerIds = listOf(1, 2)
val nonces = signerIds.map { nonceOf(it, message, "b".repeat(63) + "${it + 1}") }
val session = sessionFor(signerIds, nonces.map { it.second }, message)
val partials = signerIds.mapIndexed { position, signerId ->
session.sign(nonces[position].first, keyMaterial.secretShares[signerId], signerId.toUInt()).right!!
}
val signature = session.aggregateSigs(partials).right!!
assertTrue(
Nip01Crypto.verify(
signature = signature.toByteArray(),
hash = id.hexToByteArray(),
pubKey = groupPubKey.hexToByteArray()
)
)
}
@Test
fun `a signature over one event does not verify against another`() {
val id = eventId("the group agrees")
val message = ByteVector(id.hexToByteArray())
val signerIds = listOf(0, 1)
val nonces = signerIds.map { nonceOf(it, message, "c".repeat(63) + "${it + 1}") }
val session = sessionFor(signerIds, nonces.map { it.second }, message)
val partials = signerIds.mapIndexed { position, signerId ->
session.sign(nonces[position].first, keyMaterial.secretShares[signerId], signerId.toUInt()).right!!
}
val signature = session.aggregateSigs(partials).right!!
assertFalse(
Nip01Crypto.verify(
signature = signature.toByteArray(),
hash = eventId("the group agrees to something else").hexToByteArray(),
pubKey = groupPubKey.hexToByteArray()
),
"a signature is over one event id and must not carry to another"
)
}
@Test
fun `regenerating a nonce from the same seed and message gives the same nonce`() {
val id = eventId("the group agrees")
val message = ByteVector(id.hexToByteArray())
val random = "d".repeat(63) + "1"
// What makes a signing session restart-safe: SecretNonce cannot be stored,
// so the manager keeps its seed and derives again. If that were not
// reproducible a device that restarted mid-session would publish a partial
// signature against a nonce nobody aggregated.
val first = nonceOf(0, message, random).second
val second = nonceOf(0, message, random).second
assertEquals(first.data.toHex(), second.data.toHex())
}
@Test
fun `the same seed under a different message gives a different nonce`() {
val random = "e".repeat(63) + "1"
// The safety property behind reusing the seed at all: one session signs one
// message. Were the nonce independent of the message, a session that could
// be re-pointed at another event would sign twice under one nonce, which
// hands over the secret share.
val first = nonceOf(0, ByteVector(eventId("one thing").hexToByteArray()), random).second
val second = nonceOf(0, ByteVector(eventId("another thing").hexToByteArray()), random).second
assertFalse(first.data.toHex() == second.data.toHex())
}
}
/**
* The pure bits of a signing session's bookkeeping: who is signing, and with
* which key.
*/
class FrostSigningSessionTest {
private fun session(signerId: Int, signerIds: String?) = FrostSigningSession(
id = "s".repeat(64),
chatRoomId = "room",
coordinatorPublicKey = "c".repeat(64),
userPublicKey = "u".repeat(64),
dkgSessionId = "k".repeat(64),
threshold = 2,
participantCount = 3,
signerId = signerId,
unsignedEventJson = "{}",
eventId = "e".repeat(64),
nonceRandom = "f".repeat(64),
signerIds = signerIds
)
@Test
fun `a member left out of the signer set is not a signer`() {
assertTrue(session(signerId = 1, signerIds = "0,1").isSigner())
assertFalse(session(signerId = 2, signerIds = "0,1").isSigner())
}
@Test
fun `nobody is a signer until the coordinator has chosen`() {
assertFalse(session(signerId = 0, signerIds = null).isSigner())
}
@Test
fun `the signer set keeps the order it was aggregated in`() {
// FROST binds the set into the challenge, so this list is not a set of ids
// but a sequence positionally matched to the aggregated nonce.
assertEquals(listOf(2, 0, 1), session(signerId = 0, signerIds = "2,0,1").signerIdList())
}
@Test
fun `a signer set tag survives the trip through a tag array`() {
val tags = FrostSigningEvents.assembleTags(
sessionId = "session",
dkgSessionId = "ceremony",
signerIds = listOf(2, 0, 1)
)
assertEquals("session", FrostSigningEvents.parseSessionId(tags))
assertEquals("ceremony", FrostSigningEvents.parseKey(tags))
assertEquals(listOf(2, 0, 1), FrostSigningEvents.parseSignerIds(tags))
}
@Test
fun `a ceremony that recorded no public shares reads back null rather than empty`() {
// Ceremonies completed before the column existed. Signing falls back to not
// cross-checking shares, which the FROST API allows, rather than refusing.
val ceremony = DkgSession(
id = "k".repeat(64),
chatRoomId = "room",
coordinatorPublicKey = "c".repeat(64),
userPublicKey = "u".repeat(64),
threshold = 2,
participantCount = 3,
hostPublicKey = "h".repeat(66),
round1Random = "1".repeat(64),
round2AuxRandom = "2".repeat(64)
)
assertEquals(null, ceremony.publicShareList())
assertEquals(
2,
ceremony.copy(
publicShares = listOf(
Hex.encode(ByteArray(33) { 2 }),
Hex.encode(ByteArray(33) { 3 })
).joinToString(",")
).publicShareList()?.size
)
}
}

View File

@@ -0,0 +1,245 @@
package press.mantra.compose.managers
import com.vitorpamplona.quartz.nip01Core.core.Event
import fr.acinq.bitcoin.PrivateKey
import fr.acinq.secp256k1.Hex
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.extensions.toHex
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
/**
* What a room's key-state announcement is allowed to convince a member of.
*
* The announcement is made by the coordinator, and the coordinator is untrusted
* by construction -- the same assumption every other part of the ceremony is
* written under. So the interesting cases here are all the ones where a state
* is *wrong*: a member who acts on a state naming a key their room was not made
* from signs with a share that cannot aggregate, or worse, treats a key the
* group does not hold as the key the group holds.
*
* `GroupKeyStateManager` needs a database and so cannot be stood up here. What
* can be is the check it defers to, which is where the whole trust model lives.
*/
class GroupKeyStateTest {
/** Stands in for a ceremony's output. Any valid point will do. */
private val thresholdPublicKey = PrivateKey(
Hex.decode("1c0ffee0000000000000000000000000000000000000000000000000000000a1")
).publicKey().value.toHex()
/** A second group's key, for the states that name the wrong one. */
private val otherKey = PrivateKey(
Hex.decode("2bada550000000000000000000000000000000000000000000000000000000b2")
).publicKey().value.toHex()
private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
private val chatRoomId = SharedKeyDerivation.marmotGroupId(thresholdPublicKey, path)
private fun state(
chatRoomId: String = this.chatRoomId,
thresholdPublicKey: String = this.thresholdPublicKey,
derivationPath: String = SharedKeyDerivation.formatPath(path)
) = GroupKeyState(
chatRoomId = chatRoomId,
dkgSessionId = "ceremony-1",
thresholdPublicKey = thresholdPublicKey,
derivationPath = derivationPath,
announcedBy = "c00rd1na70r",
announcedAt = Instant.fromEpochSeconds(1_700_000_000)
)
@Test
fun `a state describing the room it was announced in verifies`() {
assertTrue(state().verifies())
}
@Test
fun `a state naming another group's key does not verify`() {
// The attack this is here for: a coordinator pointing the room at a key
// the group never made, so that everything signed in it is signed by
// whoever holds that key instead.
assertFalse(state(thresholdPublicKey = otherKey).verifies())
}
@Test
fun `a state naming the right key at the wrong path does not verify`() {
// The path is half the derivation, so getting it wrong reaches a
// different room just as surely as getting the key wrong does.
assertFalse(state(derivationPath = "m/9420/0/1").verifies())
}
@Test
fun `a state for one room does not verify against another`() {
assertFalse(state(chatRoomId = otherKey).verifies())
}
@Test
fun `a state carrying an unwalkable path does not verify`() {
// Hardened derivation needs the parent private key, which nobody in a
// threshold group has, so a hardened path was never walked to anything.
assertFalse(state(derivationPath = "m/9420'/0/0").verifies())
assertFalse(state(derivationPath = "9420/0/0").verifies())
assertFalse(state(derivationPath = "").verifies())
}
@Test
fun `the tags a state is announced on read back as they were written`() {
val tags = GroupKeyStateEvent.assembleTags(
chatRoomId = chatRoomId,
dkgSessionId = "ceremony-1",
path = path
)
assertEquals(chatRoomId, GroupKeyStateEvent.parseChatRoomId(tags))
assertEquals("ceremony-1", GroupKeyStateEvent.parseDkgSessionId(tags))
assertEquals(path, GroupKeyStateEvent.parsePath(tags))
}
@Test
fun `a threshold key is only read out of content that is one`() {
assertEquals(thresholdPublicKey, GroupKeyStateEvent.parseThresholdPublicKey(thresholdPublicKey))
// 32 bytes is an x-only key, not the 33-byte compressed point a ceremony
// reports; anything else is not a key at all.
assertNull(GroupKeyStateEvent.parseThresholdPublicKey(chatRoomId))
assertNull(GroupKeyStateEvent.parseThresholdPublicKey(""))
assertNull(GroupKeyStateEvent.parseThresholdPublicKey("not a key"))
assertNull(GroupKeyStateEvent.parseThresholdPublicKey("z".repeat(66)))
}
@Test
fun `a path survives the trip through a tag`() {
val deep = listOf(9420L, 7L, 0L, 1L)
val tags = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", deep)
assertEquals(deep, GroupKeyStateEvent.parsePath(tags))
}
@Test
fun `a room derived at a path other than the default still verifies at it`() {
// The reason the path is announced rather than assumed: a lookup that
// hardcodes MARMOT_ADMIN_GROUP_PATH cannot find this room at all.
val sibling = listOf(9420L, 0L, 1L)
val siblingRoom = SharedKeyDerivation.marmotGroupId(thresholdPublicKey, sibling)
assertTrue(
state(
chatRoomId = siblingRoom,
derivationPath = SharedKeyDerivation.formatPath(sibling)
).verifies()
)
}
// ---- The announcement as it actually arrives -------------------------
//
// Everything above checks the verdict on a state already assembled. These
// check the assembling: a real Event, with the tags and content an
// announcement is carried on, through the function the inbound path calls.
private fun announcement(
content: String = thresholdPublicKey,
tags: Array<Array<String>> = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", path),
pubKey: String = "c00rd1na70r",
createdAt: Long = 1_700_000_000
) = Event(
id = "an-id",
pubKey = pubKey,
createdAt = createdAt,
kind = GroupKeyStateEvent.KIND,
tags = tags,
content = content,
sig = ""
)
@Test
fun `an announcement of the room it arrives in is taken`() {
val state = GroupKeyStateManager.stateFrom(chatRoomId, announcement())
assertEquals(chatRoomId, state?.chatRoomId)
assertEquals("ceremony-1", state?.dkgSessionId)
assertEquals(thresholdPublicKey, state?.thresholdPublicKey)
assertEquals("m/9420/0/0", state?.derivationPath)
// Attribution and ordering come off the event, not off the clock.
assertEquals("c00rd1na70r", state?.announcedBy)
assertEquals(Instant.fromEpochSeconds(1_700_000_000), state?.announcedAt)
}
@Test
fun `an announcement naming another group's key is dropped`() {
// The one that matters: a coordinator pointing the room at a key the
// group never made. Everything else here is malformed input; this is
// well-formed input that lies.
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, announcement(content = otherKey)))
}
@Test
fun `an announcement addressed to another room is dropped`() {
val elsewhere = GroupKeyStateEvent.assembleTags(otherKey, "ceremony-1", path)
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, announcement(tags = elsewhere)))
}
@Test
fun `an announcement missing any of what it has to say is dropped`() {
val full = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", path)
// No ceremony to reach a share through.
assertNull(
GroupKeyStateManager.stateFrom(
chatRoomId,
announcement(tags = full.filterNot { it[0] == "frost_key" }.toTypedArray())
)
)
// No path, so nothing to rebuild a TweakCache from.
assertNull(
GroupKeyStateManager.stateFrom(
chatRoomId,
announcement(tags = full.filterNot { it[0] == "frost_path" }.toTypedArray())
)
)
// No key.
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, announcement(content = "")))
}
@Test
fun `an announcement carrying no d tag is judged on its derivation alone`() {
// The d tag is a convenience for a reader holding the event on its own.
// Dropping it loses nothing that matters, because the room it arrived in
// plus the derivation still settle the question.
val undirected = arrayOf(
arrayOf("frost_key", "ceremony-1"),
arrayOf("frost_path", "m/9420/0/0")
)
assertEquals(
chatRoomId,
GroupKeyStateManager.stateFrom(chatRoomId, announcement(tags = undirected))?.chatRoomId
)
}
@Test
fun `a path index wider than a uint32 is not a path`() {
// tweakScalar serialises an index as its low four bytes, so m/4294967296
// would otherwise walk exactly where m/0 does -- one room, two spellings,
// both verifying. Rejected at the parser so formatPath stays a round trip.
assertNull(SharedKeyDerivation.parsePathString("m/4294967296/0/0"))
assertNull(SharedKeyDerivation.parsePathString("m/-1/0/0"))
assertEquals(listOf(4294967295L), SharedKeyDerivation.parsePathString("m/4294967295"))
assertEquals(listOf(0L), SharedKeyDerivation.parsePathString("m/0"))
}
@Test
fun `a state whose path indices are out of range does not verify`() {
// Reachable only by constructing the row directly; the parser above
// refuses to build one. Checked because verifies() is what everything
// else defers to, and it should not be the thing that trusts its input.
assertFalse(state(derivationPath = "m/4294967296/0/0").verifies())
}
}

View File

@@ -0,0 +1,201 @@
package press.mantra.compose.managers
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNull
import kotlin.test.assertSame
import kotlinx.coroutines.runBlocking
/**
* The decision behind keeping an MLS group alive between messages.
*
* This cache exists because quartz drops a secret tree's skipped-generation keys
* on save, so rebuilding a group between two messages loses any message that
* arrives late -- permanently, and silently. See `docs/mls-skipped-keys.md`.
*
* Every one of these failures is invisible at runtime. Reuse too eagerly and a
* group carries on from a ratchet another writer has already moved, which
* corrupts decryption rather than failing it. Reuse too rarely and the cache
* does nothing at all, and the bug it was written for comes straight back with
* no symptom to notice. So the rule is asserted rather than reasoned about.
*/
class LiveInstanceCacheTest {
/** Stands in for an MlsGroup: mutable, and its persisted form is its content. */
private class Group(var state: String) {
/** How many times this particular instance was handed to a caller. */
var uses: Int = 0
}
private fun cache() = LiveInstanceCache<Group> { it.state }
@Test
fun `reuses the instance while nothing else has written`() = runBlocking {
val cache = cache()
var stored: String? = "start"
var built = 0
val first = cache.withInstance(
key = "room",
storedState = stored,
build = { built++; Group("start") },
save = { stored = it },
block = { it.uses++; it }
)
val second = cache.withInstance(
key = "room",
storedState = stored,
build = { built++; Group("start") },
save = { stored = it },
block = { it.uses++; it }
)
// The same object, not merely an equal one: what has to survive is the
// in-memory skipped-key map, which no amount of rebuilding recovers.
assertSame(first, second)
assertEquals(1, built)
assertEquals(2, second?.uses)
}
@Test
fun `rebuilds when something else wrote the stored state`() = runBlocking {
val cache = cache()
var stored: String? = "start"
var built = 0
val first = cache.withInstance(
key = "room",
storedState = stored,
build = { built++; Group("start") },
save = { stored = it },
block = { it }
)
// Sending a message advances the sender ratchet and saves; adding a
// member does too. Carrying on from an instance that has been overtaken
// would diverge the ratchet, which is worse than not caching at all.
stored = "written by someone else"
val second = cache.withInstance(
key = "room",
storedState = stored,
build = { built++; Group("written by someone else") },
save = { stored = it },
block = { it }
)
assertEquals(2, built)
assertEquals(false, first === second)
}
@Test
fun `persists whatever the block left behind`() = runBlocking {
val cache = cache()
var stored: String? = "start"
cache.withInstance(
key = "room",
storedState = stored,
build = { Group("start") },
save = { stored = it },
block = { it.state = "advanced" }
)
assertEquals("advanced", stored)
}
@Test
fun `the state it records is the one it compares against next time`() = runBlocking {
val cache = cache()
var stored: String? = "start"
var built = 0
repeat(3) {
cache.withInstance(
key = "room",
storedState = stored,
build = { built++; Group("start") },
save = { stored = it },
// Every use moves the instance on, as decrypting a message does.
block = { group -> group.state = "advanced ${group.uses++}" }
)
}
// Recording the pre-block state instead would make every call look like
// somebody else had written, quietly turning the cache off.
assertEquals(1, built)
}
@Test
fun `does not run the block, or cache anything, when there is nothing to build`() = runBlocking {
val cache = cache()
var ran = false
val result = cache.withInstance<Unit>(
key = "room",
storedState = null,
build = { null },
save = { },
block = { ran = true }
)
assertNull(result)
assertEquals(false, ran)
assertEquals(0, cache.size())
}
@Test
fun `an instance whose use threw is not handed to the next caller`() = runBlocking {
val cache = cache()
var stored: String? = "start"
var built = 0
assertFailsWith<IllegalStateException> {
cache.withInstance<Unit>(
key = "room",
storedState = stored,
build = { built++; Group("start") },
save = { stored = it },
block = { error("decryption blew up half way") }
)
}
cache.withInstance(
key = "room",
storedState = stored,
build = { built++; Group("start") },
save = { stored = it },
block = { it }
)
// Half-advanced and never persisted: the next caller has to start from
// what is actually on disk, not from whatever the failure left in memory.
assertEquals(2, built)
}
@Test
fun `rooms are cached independently`() = runBlocking {
val cache = cache()
var storedA: String? = "a"
var storedB: String? = "b"
val a = cache.withInstance(
key = "roomA",
storedState = storedA,
build = { Group("a") },
save = { storedA = it },
block = { it }
)
val b = cache.withInstance(
key = "roomB",
storedState = storedB,
build = { Group("b") },
save = { storedB = it },
block = { it }
)
assertEquals(2, cache.size())
assertEquals(false, a === b)
}
}

View File

@@ -0,0 +1,80 @@
package press.mantra.compose.nostr
import press.mantra.compose.database.query.NostrEventFilterQuery
import press.mantra.compose.network.serialization.encodeToJsonString
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* Pins the only gift wrap filter that can come back with something we can read.
*
* Every clause here is one that was got wrong in production. Two call sites asked
* for `authors = [our pubkey]`, which cannot match a wrap signed by a throwaway
* key and so returned nothing at all, silently, for as long as it existed. A third
* asked for `p = [the peer]`, which returned other people's mail and crashed the
* save that tried to unseal it. Neither failure was visible from reading the
* filter, so the shape is asserted instead of trusted.
*/
class Nip17FiltersTest {
private val us = "a".repeat(64)
@Test
fun `it asks for wraps addressed to us`() {
assertEquals(mapOf("p" to listOf(us)), Nip17Filters.inbox(us).tags)
}
@Test
fun `it constrains no authors`() {
// GiftWrapEvent.create signs with a key it generates and drops, so the author
// of a wrap is a value nobody can predict -- least of all the sender's own
// pubkey. Any authors clause here silently matches zero events on every relay.
assertNull(Nip17Filters.inbox(us).authors)
}
@Test
fun `it carries no since cursor`() {
// A wrap is stamped up to two days earlier than it was sent, so a high-water
// mark taken from the newest wrap we hold skips mail that arrives behind it.
// Anything reintroducing `since` has to back-date by at least two days first.
assertNull(Nip17Filters.inbox(us).since)
assertNull(Nip17Filters.inbox(us).until)
}
@Test
fun `it asks for gift wraps and nothing else`() {
assertEquals(listOf(1059), Nip17Filters.inbox(us).kinds?.toList())
}
@Test
fun `two callers asking for the same inbox make one request`() {
// computeId hashes the encoded filter, so the chat room list and the chat
// message screen collapse into a single negentropy request only while both
// encode identically. Building the filter once is what holds that true; the
// wire shape is asserted so an added default cannot quietly split them.
assertEquals(Nip17Filters.inbox(us), Nip17Filters.inbox(us))
assertEquals(
"""{"kinds":[1059],"tags":{"p":["$us"]},"limit":50}""",
Nip17Filters.inbox(us).encodeToJsonString(),
)
}
@Test
fun `the local set it builds is the same set the relay is asked for`() {
// Negentropy reconciles our local set against the relay's: this filter goes out
// in NEG-OPEN, and the local side is built by running the same filter through
// NostrEventFilterQuery. A clause that survives one trip and not the other
// reports differences that are not real -- events re-downloaded forever, or
// pushed at a relay that excluded them on purpose. What matters here is that
// the local query reads the p tag and, like the wire filter, bounds no author:
// an authors clause would show up as `pubKey IN (?)`.
val query = NostrEventFilterQuery.build(Nip17Filters.inbox(us))
assertEquals(
"SELECT * FROM NostrEvent WHERE kind IN (?) AND (tags LIKE ? ESCAPE '\\') " +
"ORDER BY createdAt DESC, id DESC",
query.sql,
)
}
}

View File

@@ -0,0 +1,126 @@
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.assertNotEquals
import kotlin.test.assertNull
/**
* What a submission has to survive: the trip through a group.
*
* The envelope is only worth having if the event inside it comes out the other
* side unchanged -- same id, same author, same signature. The moment any of
* those is rewritten in transit, a group can no longer hold work by anyone but
* its own members, which is the whole reason submissions exist.
*/
class SubmissionEventTest {
private val submitter = "a".repeat(64)
private val outsider = "b".repeat(64)
/** A dialect written by somebody who is not in the group. */
private fun outsiderDialect(): Event {
val template = DialectEvent.build(
name = "Sesotho",
country = "Lesotho",
language = "st",
createdAt = 1_700_000_000L,
)
return Event(
id = EventHasher.hashId(
pubKey = outsider,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content,
),
pubKey = outsider,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content,
sig = "c".repeat(128),
)
}
/** Send a submission template and read it back the way inbound does. */
private fun roundTrip(payload: Event): SubmissionEvent {
val template = SubmissionEvent.build(payload = payload, createdAt = 1_700_000_100L)
val onTheWire = Event.fromJson(
Event(
id = EventHasher.hashId(
pubKey = submitter,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content,
),
pubKey = submitter,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content,
sig = "",
).toJson()
)
return SubmissionEvent(
id = onTheWire.id,
pubKey = onTheWire.pubKey,
createdAt = onTheWire.createdAt,
tags = onTheWire.tags,
content = onTheWire.content,
sig = onTheWire.sig,
)
}
@Test
fun `the payload comes back as the event that went in`() {
val dialect = outsiderDialect()
val payload = roundTrip(dialect).payload()
assertEquals(dialect.id, payload?.id)
assertEquals(dialect.pubKey, payload?.pubKey)
assertEquals(dialect.kind, payload?.kind)
assertEquals(dialect.content, payload?.content)
assertEquals(dialect.sig, payload?.sig)
}
@Test
fun `submitting does not make the submitter the author`() {
val dialect = outsiderDialect()
val submission = roundTrip(dialect)
assertEquals(submitter, submission.pubKey)
assertEquals(outsider, submission.payload()?.pubKey)
assertNotEquals(submission.pubKey, submission.payload()?.pubKey)
}
@Test
fun `the envelope names what it carries without being opened`() {
val dialect = outsiderDialect()
val submission = roundTrip(dialect)
assertEquals(DialectEvent.KIND, submission.payloadKind())
assertEquals(dialect.id, submission.payloadId())
assertEquals(outsider, submission.payloadAuthor())
}
@Test
fun `a payload the group cannot read is null rather than empty`() {
val submission = SubmissionEvent(
id = "d".repeat(64),
pubKey = submitter,
createdAt = 1_700_000_100L,
tags = arrayOf(),
content = "not an event",
sig = "",
)
assertNull(submission.payload())
}
}