Add deterministic id on MLS related models

This commit is contained in:
Kgothatso Ngako committed 2026-07-01 20:27:05 +02:00
1 parent 0957f710d9
commit aad97bdd9e
27 files changed
+633 -397

No files matched your search

@@ -2,7 +2,7 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 1, "version": 1,
"identityHash": "f33937ebefe679fe03b71b3930472139", "identityHash": "8dd191b21e518a19f39b50a5f5b4a432",
"entities": [ "entities": [
{ {
"tableName": "BroadcastNostrEventReceipt", "tableName": "BroadcastNostrEventReceipt",
@@ -282,7 +282,7 @@
}, },
{ {
"tableName": "ChatMessage", "tableName": "ChatMessage",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `senderPublicKey` TEXT NOT NULL, `isUserMessage` INTEGER NOT NULL, `giftWrapPayloadId` INTEGER NOT NULL, `chatRoomId` TEXT NOT NULL, `replyToMessageId` INTEGER, `quotedMessageId` INTEGER, `content` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`giftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `senderPublicKey` TEXT NOT NULL, `isUserMessage` INTEGER NOT NULL, `giftWrapPayloadId` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `replyToMessageId` INTEGER, `quotedMessageId` INTEGER, `content` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`giftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [ "fields": [
{ {
"fieldPath": "id", "fieldPath": "id",
@@ -305,7 +305,7 @@
{ {
"fieldPath": "giftWrapPayloadId", "fieldPath": "giftWrapPayloadId",
"columnName": "giftWrapPayloadId", "columnName": "giftWrapPayloadId",
"affinity": "INTEGER", "affinity": "TEXT",
"notNull": true "notNull": true
}, },
{ {
@@ -608,7 +608,7 @@
}, },
{ {
"tableName": "ChatRoom", "tableName": "ChatRoom",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `subject` TEXT, `mlsGroupState` TEXT, `initialGiftWrapPayloadId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`userPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`initialGiftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `subject` TEXT, `mlsGroupState` TEXT, `initialGiftWrapPayloadId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`userPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`initialGiftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [ "fields": [
{ {
"fieldPath": "id", "fieldPath": "id",
@@ -635,7 +635,7 @@
{ {
"fieldPath": "initialGiftWrapPayloadId", "fieldPath": "initialGiftWrapPayloadId",
"columnName": "initialGiftWrapPayloadId", "columnName": "initialGiftWrapPayloadId",
"affinity": "INTEGER" "affinity": "TEXT"
}, },
{ {
"fieldPath": "createdAt", "fieldPath": "createdAt",
@@ -893,12 +893,12 @@
}, },
{ {
"tableName": "GiftWrapPayload", "tableName": "GiftWrapPayload",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `quotedEventId` TEXT, `giftWrapSealId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, FOREIGN KEY(`giftWrapSealId`) REFERENCES `GiftWrapSeal`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `quotedEventId` TEXT, `giftWrapSealId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`giftWrapSealId`) REFERENCES `GiftWrapSeal`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [ "fields": [
{ {
"fieldPath": "id", "fieldPath": "id",
"columnName": "id", "columnName": "id",
"affinity": "INTEGER", "affinity": "TEXT",
"notNull": true "notNull": true
}, },
{ {
@@ -971,7 +971,7 @@
} }
], ],
"primaryKey": { "primaryKey": {
"autoGenerate": true, "autoGenerate": false,
"columnNames": [ "columnNames": [
"id" "id"
] ]
@@ -1117,12 +1117,12 @@
}, },
{ {
"tableName": "MarmotKeyPackage", "tableName": "MarmotKeyPackage",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `publicKey` TEXT NOT NULL, `tlsEncodedMarmotKeyPackage` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, FOREIGN KEY(`publicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE )", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `tlsEncodedMarmotKeyPackage` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`publicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [ "fields": [
{ {
"fieldPath": "id", "fieldPath": "id",
"columnName": "id", "columnName": "id",
"affinity": "INTEGER", "affinity": "TEXT",
"notNull": true "notNull": true
}, },
{ {
@@ -1167,7 +1167,7 @@
} }
], ],
"primaryKey": { "primaryKey": {
"autoGenerate": true, "autoGenerate": false,
"columnNames": [ "columnNames": [
"id" "id"
] ]
@@ -1188,12 +1188,12 @@
}, },
{ {
"tableName": "MarmotKeyPackageBundle", "tableName": "MarmotKeyPackageBundle",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `publicKey` TEXT NOT NULL, `tlsEncodedMarmotKeyPackage` TEXT NOT NULL, `ncryptsecInitPrivateKey` TEXT NOT NULL, `ncryptsecEncryptionPrivateKey` TEXT NOT NULL, `ncryptsecSignaturePrivateKey` TEXT NOT NULL, `consumed` INTEGER NOT NULL, `rotated` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER)", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `tlsEncodedMarmotKeyPackage` BLOB NOT NULL, `ncryptsecInitPrivateKey` TEXT NOT NULL, `ncryptsecEncryptionPrivateKey` TEXT NOT NULL, `ncryptsecSignaturePrivateKey` TEXT NOT NULL, `consumed` INTEGER NOT NULL, `rotated` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`))",
"fields": [ "fields": [
{ {
"fieldPath": "id", "fieldPath": "id",
"columnName": "id", "columnName": "id",
"affinity": "INTEGER", "affinity": "TEXT",
"notNull": true "notNull": true
}, },
{ {
@@ -1205,7 +1205,7 @@
{ {
"fieldPath": "tlsEncodedMarmotKeyPackage", "fieldPath": "tlsEncodedMarmotKeyPackage",
"columnName": "tlsEncodedMarmotKeyPackage", "columnName": "tlsEncodedMarmotKeyPackage",
"affinity": "TEXT", "affinity": "BLOB",
"notNull": true "notNull": true
}, },
{ {
@@ -1268,7 +1268,7 @@
} }
], ],
"primaryKey": { "primaryKey": {
"autoGenerate": true, "autoGenerate": false,
"columnNames": [ "columnNames": [
"id" "id"
] ]
@@ -3044,7 +3044,7 @@
], ],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f33937ebefe679fe03b71b3930472139')" "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8dd191b21e518a19f39b50a5f5b4a432')"
] ]
} }
} }
@@ -4,6 +4,7 @@ import androidx.room3.Dao
import androidx.room3.Query import androidx.room3.Query
import androidx.room3.Transaction import androidx.room3.Transaction
import androidx.room3.Upsert import androidx.room3.Upsert
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Dao @Dao
@@ -17,7 +18,7 @@ interface ChatMessageDao {
fun getChatMessagesByChatRoomId(chatRoomId: String): List<at.torch.compose.database.model.intermdiate.LocalChatMessage> fun getChatMessagesByChatRoomId(chatRoomId: String): List<at.torch.compose.database.model.intermdiate.LocalChatMessage>
@Query("SELECT * FROM ChatMessage WHERE giftWrapPayloadId = :giftWrapPayloadId ORDER BY createdAt DESC") @Query("SELECT * FROM ChatMessage WHERE giftWrapPayloadId = :giftWrapPayloadId ORDER BY createdAt DESC")
fun getChatMessagesByGiftWrapPayloadId(giftWrapPayloadId: Long): at.torch.compose.database.model.ChatMessage? fun getChatMessagesByGiftWrapPayloadId(giftWrapPayloadId: HexKey): at.torch.compose.database.model.ChatMessage?
@Upsert @Upsert
suspend fun upsert(chatMessage: at.torch.compose.database.model.ChatMessage): Long suspend fun upsert(chatMessage: at.torch.compose.database.model.ChatMessage): Long
@@ -14,5 +14,5 @@ interface GiftWrapPayloadDao {
fun observeUnsealedGiftWrapPayloads(publicKey: String): Flow<at.torch.compose.database.model.GiftWrapPayload?> fun observeUnsealedGiftWrapPayloads(publicKey: String): Flow<at.torch.compose.database.model.GiftWrapPayload?>
@Upsert @Upsert
suspend fun upsert(giftWrapPayload: at.torch.compose.database.model.GiftWrapPayload): Long suspend fun upsert(giftWrapPayload: at.torch.compose.database.model.GiftWrapPayload)
} }
@@ -7,8 +7,8 @@ import at.torch.compose.database.model.MarmotKeyPackageBundle
@Dao @Dao
interface MarmotKeyPackageBundleDao { interface MarmotKeyPackageBundleDao {
@Query("SELECT * FROM MarmotKeyPackageBundle WHERE id = :keyPackageEventId ORDER BY createdAt DESC") @Query("SELECT * FROM MarmotKeyPackageBundle WHERE id = :id ORDER BY createdAt DESC")
fun getMarmotKeyPackageBundleByKeyPackageEventIdId(keyPackageEventId: String): MarmotKeyPackageBundle? fun getMarmotKeyPackageBundleById(id: String): MarmotKeyPackageBundle?
@Upsert @Upsert
suspend fun upsert(marmotKeyPackageBundle: MarmotKeyPackageBundle) suspend fun upsert(marmotKeyPackageBundle: MarmotKeyPackageBundle)
@@ -18,10 +18,8 @@ import at.torch.compose.database.model.types.SynchronizationFilter
import at.torch.compose.exceptions.GiftWrapImpersonationException import at.torch.compose.exceptions.GiftWrapImpersonationException
import at.torch.compose.exceptions.GiftWrapSealDecryptionException import at.torch.compose.exceptions.GiftWrapSealDecryptionException
import at.torch.compose.exceptions.GiftWrapUnsealException import at.torch.compose.exceptions.GiftWrapUnsealException
import at.torch.compose.exceptions.MarmotMissingKeyPackageEventIdException import at.torch.compose.exceptions.MarmotMissingKeyPackageBundleException
import at.torch.compose.exceptions.MarmotMissingNostrGroupDataExtension import at.torch.compose.exceptions.MarmotWelcomeEventMissingKeyPackageEventIdException
import at.torch.compose.exceptions.MarmotNoMatchingKeyPackageBundleException
import at.torch.compose.exceptions.MarmotWelcomeMismatchedGroupIdException
import at.torch.compose.extensions.toHex import at.torch.compose.extensions.toHex
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
@@ -39,6 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import kotlin.io.encoding.Base64 import kotlin.io.encoding.Base64
@@ -127,7 +126,8 @@ abstract class NostrDao(
logger.i("This is a placeholder profile might need to get synced...: ${profile.publicKey}") logger.i("This is a placeholder profile might need to get synced...: ${profile.publicKey}")
// Setup the sync here... // Setup the sync here...
} else { } else {
return // TODO: Check if event got indexed...
// return
} }
} }
@@ -388,257 +388,397 @@ abstract class NostrDao(
logger.e("Impersonation", giftWrapImpersonation) logger.e("Impersonation", giftWrapImpersonation)
throw giftWrapImpersonation // Once this is thrown the database transaction fails and nothing gets saved to the DB... throw giftWrapImpersonation // Once this is thrown the database transaction fails and nothing gets saved to the DB...
} }
val result = database.giftWrapPayloadDao().upsert(
database.giftWrapPayloadDao().upsert(
decryptedGiftWrapPayload decryptedGiftWrapPayload
) )
val giftWrapPayload = if (result != -1L) { if (decryptedGiftWrapPayload.kind == ChatMessageEvent.KIND) {
decryptedGiftWrapPayload.copy( logger.d("chatMessageEvent: $decryptedGiftWrapPayload")
id = result // Handle normal nip-17 messages...
val localChatRoom = database.chatRoomDao().findChatRoomById(
decryptedGiftWrapPayload.chatRoomId
) )
} else {
decryptedGiftWrapPayload
}
// TODO: Handle MLS message/groups if (localChatRoom == null) {
if (giftWrapPayload.kind == WelcomeEvent.KIND) { val userPublicKey = activeKeyPair.pubKey.toHex()
logger.d("Welcome Event") database.chatRoomDao().upsert(
ChatRoom(
id = decryptedGiftWrapPayload.chatRoomId,
userPublicKey = userPublicKey,
subject = decryptedGiftWrapPayload.parseSubject(),
createdAt = decryptedGiftWrapPayload.createdAt,
initialGiftWrapPayloadId = decryptedGiftWrapPayload.id,
mlsGroupState = null
)
)
// Add participants...
val participants = decryptedGiftWrapPayload.participantPTags(
NormalizedRelayUrl(relayURL) // TODO: Get relayURL for publicKey profile...
).map {
Participant(
participantPublicKey = it.pubKey,
chatRoomId = decryptedGiftWrapPayload.chatRoomId,
relayHint = it.relayHint?.url
)
}
// Sync missing participant profiles...
participants.forEach { participant ->
val giftWrapParticipantProfile =
database.profileDao()
.getProfileByPublicKey(participant.participantPublicKey)
if (giftWrapParticipantProfile == null) {
// Save a placeholder
database.profileDao().insertPlaceholderProfile(
Profile(
displayName = "LOADING...",
publicKey = participant.participantPublicKey,
createdAt = GENESIS_AT,
nostrEventId = nostrEvent.id, // Will get overwriting by sync,
)
)
val recommendRelayUrl =
giftWrapMessage.receiverRelayHit
if (recommendRelayUrl != null) {
if (profilePublicKeysToSync[recommendRelayUrl] == null) {
profilePublicKeysToSync[recommendRelayUrl] =
mutableSetOf()
}
profilePublicKeysToSync[recommendRelayUrl]?.add(
participant.participantPublicKey
)
} else {
if (profilePublicKeysToSync[relayURL] == null) {
profilePublicKeysToSync[relayURL] =
mutableSetOf()
}
profilePublicKeysToSync[relayURL]?.add(
participant.participantPublicKey
)
}
}
}
database.participantDao().upsert(
participants
)
participants.filter { it.participantPublicKey != userPublicKey }
.forEach { participant ->
val publicKey = participant.participantPublicKey
val chatMessageRelayListEvent =
database.nostrEventDao()
.getAuthoredNostrEvents(
kinds = arrayOf(
ChatMessageRelayListEvent.KIND
),
authors = arrayOf(
publicKey
),
since = GENESIS_AT,
limit = 5
).firstOrNull()?.let { nostrEvent ->
if (nostrEvent.kind != ChatMessageRelayListEvent.KIND) {
logger.e("getAuthoredNostrEvents returned invalid ChatMessageRelayListEvent for ${publicKey}: $nostrEvent")
null
} else {
ChatMessageRelayListEvent(
id = nostrEvent.id,
tags = nostrEvent.tags,
pubKey = nostrEvent.pubKey,
content = nostrEvent.content,
createdAt = nostrEvent.createdAt.epochSeconds,
sig = nostrEvent.sig
)
}
}
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 {
logger.w("We don't have a chatMessageRelayListEvent for the pubkey $publicKey")
// Sync ChatMessageRelayListEvent publicKey...
// TODO: Get relayHint form participant...
if (profilePublicKeysToSync.containsKey(
relayURL
)
) {
profilePublicKeysToSync[relayURL] =
mutableSetOf()
}
profilePublicKeysToSync[relayURL]?.add(
participant.participantPublicKey
)
}
}
} else {
decryptedGiftWrapPayload.parseSubject()?.let { subject ->
database.chatRoomDao().upsert(
localChatRoom.chatRoom.copy(
subject = subject,
updatedAt = decryptedGiftWrapPayload.createdAt
)
)
}
}
} else if (decryptedGiftWrapPayload.kind == WelcomeEvent.KIND) {
val welcomeEvent = WelcomeEvent( val welcomeEvent = WelcomeEvent(
id = "", // TODO: Need the ID as string in decryptedGiftWrapPayload... id = decryptedGiftWrapPayload.id, // TODO: Need the ID as string in decryptedGiftWrapPayload...
pubKey = decryptedGiftWrapPayload.publicKey, pubKey = decryptedGiftWrapPayload.publicKey,
content = decryptedGiftWrapPayload.content, content = decryptedGiftWrapPayload.content,
createdAt = decryptedGiftWrapPayload.createdAt.epochSeconds, createdAt = decryptedGiftWrapPayload.createdAt.epochSeconds,
tags = decryptedGiftWrapPayload.tags, tags = decryptedGiftWrapPayload.tags,
sig = "" sig = ""
) )
logger.d("welcomeEvent: $welcomeEvent")
welcomeEvent.nostrGroupId()?.let { chatRoomId -> // TODO: Find keypackage and decrypt welcome...
val localChatRoom = database.chatRoomDao().findChatRoomById( // TODO: Get keyPackage from
chatRoomId val keyPackageEventId = welcomeEvent.keyPackageEventId() ?: throw MarmotWelcomeEventMissingKeyPackageEventIdException(
"Couldn't find keyPackageEventId for $welcomeEvent"
)
val marmotKeyPackageBundle = database.marmotKeyPackageBundleDao().getMarmotKeyPackageBundleById(
keyPackageEventId
) ?: throw MarmotMissingKeyPackageBundleException(
"Couldn't find marmotKeyPackageBundle for $welcomeEvent"
)
val welcomeBytes = Base64.decode(
welcomeEvent.welcomeBase64()
)
val initPrivateKey = marmotKeyPackageBundle.ncryptsecInitPrivateKey.hexToByteArray() // TODO: Decrypt
val signaturePrivateKey = marmotKeyPackageBundle.ncryptsecSignaturePrivateKey.hexToByteArray() // TODO: Decrypt
val encryptionPrivateKey = marmotKeyPackageBundle.ncryptsecEncryptionPrivateKey.hexToByteArray() // TODO: Decrypt
val tlsReader = TlsReader(
data = marmotKeyPackageBundle.tlsEncodedMarmotKeyPackage
)
val group = MlsGroup.processWelcome(
welcomeBytes,
bundle = KeyPackageBundle(
keyPackage = MlsKeyPackage.decodeTls(
tlsReader
),
initPrivateKey = initPrivateKey,
signaturePrivateKey = signaturePrivateKey,
encryptionPrivateKey = encryptionPrivateKey
)
)
val nostrGroupId = group.groupId.toHex()
val localChatRoom = database.chatRoomDao().findChatRoomById(
nostrGroupId
)
if (localChatRoom == null) {
logger.d("processWelcome: welcomeBytes=${welcomeBytes.size}B looking up KeyPackage by ref=${keyPackageEventId.take(8)}…")
// TODO: Save the chat room We can now s
val userPublicKey = activeKeyPair.pubKey.toHex()
database.chatRoomDao().upsert(
ChatRoom(
id = nostrGroupId,
userPublicKey = userPublicKey,
subject = group.currentMarmotData()?.name,
initialGiftWrapPayloadId = decryptedGiftWrapPayload.id,
createdAt = decryptedGiftWrapPayload.createdAt,
mlsGroupState = group.saveState().encodeTls().toHex()
)
) )
if (localChatRoom == null) { database.marmotKeyPackageBundleDao().upsert(
marmotKeyPackageBundle.copy(
val welcomeBytes = Base64.decode( consumed = true
welcomeEvent.welcomeBase64()
) )
val keyPackageEventId = welcomeEvent.keyPackageEventId() )
if (keyPackageEventId == null) {
throw MarmotMissingKeyPackageEventIdException( // TODO: Add participants... by processing group.members()
"WelcomeEvent ${welcomeEvent.id} missing KeyPackage event ID tag" val normalizedRelayUrl = NormalizedRelayUrl(relayURL)
val participants = group.members().mapNotNull { (leafIndex, leafNode) ->
val pubkey =
when (val cred = leafNode.credential) {
is Credential.Basic -> cred.identity.toHexKey()
else -> null
}
if (pubkey != null) {
Participant(
participantPublicKey = pubkey,
chatRoomId = nostrGroupId,
relayHint = normalizedRelayUrl.url
) )
} else {
null
} }
}
logger.d("processWelcome: welcomeBytes=${welcomeBytes.size}B looking up KeyPackage by ref=${keyPackageEventId.take(8)}…") // Sync missing participant profiles...
participants.forEach { participant ->
val giftWrapParticipantProfile = database.profileDao().getProfileByPublicKey(participant.participantPublicKey)
// TODO: Check if we have already joined group with keyPackage if (giftWrapParticipantProfile == null) {
val marmotKeyPackageBundle = database.marmotKeyPackageBundleDao().getMarmotKeyPackageBundleByKeyPackageEventIdId( // Save a placeholder
keyPackageEventId database.profileDao().insertPlaceholderProfile(
) Profile(
displayName = "LOADING...",
if (marmotKeyPackageBundle == null) { publicKey = participant.participantPublicKey,
// We were unable to find the keyPackageBundle for this invite... createdAt = GENESIS_AT,
throw MarmotNoMatchingKeyPackageBundleException( nostrEventId = nostrEvent.id, // Will get overwriting by sync,
"NO matching KeyPackageBundle for eventId=${keyPackageEventId.take(8)}…"
)
}
val initPrivateKey = marmotKeyPackageBundle.ncryptsecInitPrivateKey.hexToByteArray() // TODO: Decrypt
val signaturePrivateKey = marmotKeyPackageBundle.ncryptsecSignaturePrivateKey.hexToByteArray() // TODO: Decrypt
val encryptionPrivateKey = marmotKeyPackageBundle.ncryptsecEncryptionPrivateKey.hexToByteArray() // TODO: Decrypt
val tlsReader = TlsReader(
data = marmotKeyPackageBundle.tlsEncodedMarmotKeyPackage.hexToByteArray()
)
val group = MlsGroup.processWelcome(
welcomeBytes,
bundle = KeyPackageBundle(
keyPackage = MlsKeyPackage.decodeTls(
tlsReader
),
initPrivateKey = initPrivateKey,
signaturePrivateKey = signaturePrivateKey,
encryptionPrivateKey = encryptionPrivateKey
)
)
val derivedId = group.currentMarmotData()?.nostrGroupId
if (derivedId == null) {
throw MarmotMissingNostrGroupDataExtension(
"The WelcomeEvent ${welcomeEvent.id} is missing the nostrGroupData extension"
)
}
if (derivedId != chatRoomId) {
throw MarmotWelcomeMismatchedGroupIdException(
"The hint group Id $chatRoomId doesn't match the derived group id $derivedId"
)
}
// TODO: Save the chat room We can now s
val userPublicKey = activeKeyPair.pubKey.toHex()
database.chatRoomDao().upsert(
ChatRoom(
id = chatRoomId,
userPublicKey = userPublicKey,
subject = group.currentMarmotData()?.name,
initialGiftWrapPayloadId = giftWrapPayload.id,
createdAt = decryptedGiftWrapPayload.createdAt,
mlsGroupState = group.saveState().encodeTls().toHex()
)
)
database.marmotKeyPackageBundleDao().upsert(
marmotKeyPackageBundle.copy(
consumed = true
)
)
// TODO: Add participants... by processing group.members()
val normalizedRelayUrl = NormalizedRelayUrl(relayURL)
val participants = group.members().mapNotNull { (leafIndex, leafNode) ->
val pubkey =
when (val cred = leafNode.credential) {
is Credential.Basic -> cred.identity.toHexKey()
else -> null
}
if (pubkey != null) {
Participant(
participantPublicKey = pubkey,
chatRoomId = chatRoomId,
relayHint = normalizedRelayUrl.url
) )
)
val recommendRelayUrl = giftWrapMessage.receiverRelayHit
if (recommendRelayUrl != null) {
if (profilePublicKeysToSync[recommendRelayUrl] == null) {
profilePublicKeysToSync[recommendRelayUrl] = mutableSetOf()
}
profilePublicKeysToSync[recommendRelayUrl]?.add(participant.participantPublicKey)
} else { } else {
null if (profilePublicKeysToSync[relayURL] == null) {
}
}
// Sync missing participant profiles...
participants.forEach { participant ->
val giftWrapParticipantProfile = database.profileDao().getProfileByPublicKey(participant.participantPublicKey)
if (giftWrapParticipantProfile == null) {
// Save a placeholder
database.profileDao().insertPlaceholderProfile(
Profile(
displayName = "LOADING...",
publicKey = participant.participantPublicKey,
createdAt = GENESIS_AT,
nostrEventId = nostrEvent.id, // Will get overwriting by sync,
)
)
val recommendRelayUrl = giftWrapMessage.receiverRelayHit
if (recommendRelayUrl != null) {
if (profilePublicKeysToSync[recommendRelayUrl] == null) {
profilePublicKeysToSync[recommendRelayUrl] = mutableSetOf()
}
profilePublicKeysToSync[recommendRelayUrl]?.add(participant.participantPublicKey)
} else {
if (profilePublicKeysToSync[relayURL] == null) {
profilePublicKeysToSync[relayURL] = mutableSetOf()
}
profilePublicKeysToSync[relayURL]?.add(
participant.participantPublicKey
)
}
}
}
database.participantDao().upsert(
participants
)
participants.filter { it.participantPublicKey != userPublicKey }.forEach { participant ->
val publicKey = participant.participantPublicKey
val chatMessageRelayListEvent = database.nostrEventDao().getAuthoredNostrEvents(
kinds = arrayOf(
ChatMessageRelayListEvent.KIND
),
authors = arrayOf(
publicKey
),
since = GENESIS_AT,
limit = 5
).firstOrNull()?.let { nostrEvent ->
if (nostrEvent.kind != ChatMessageRelayListEvent.KIND) {
logger.e("getAuthoredNostrEvents returned invalid ChatMessageRelayListEvent for ${publicKey}: $nostrEvent")
null
} else {
ChatMessageRelayListEvent(
id = nostrEvent.id,
tags = nostrEvent.tags,
pubKey = nostrEvent.pubKey,
content = nostrEvent.content,
createdAt = nostrEvent.createdAt.epochSeconds,
sig = nostrEvent.sig
)
}
}
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 {
logger.w("We don't have a chatMessageRelayListEvent for the pubkey $publicKey")
// Sync ChatMessageRelayListEvent publicKey...
// TODO: Get relayHint form participant...
if (profilePublicKeysToSync.containsKey(relayURL)) {
profilePublicKeysToSync[relayURL] = mutableSetOf() profilePublicKeysToSync[relayURL] = mutableSetOf()
} }
profilePublicKeysToSync[relayURL]?.add(participant.participantPublicKey) profilePublicKeysToSync[relayURL]?.add(
participant.participantPublicKey
)
} }
} }
} }
// Where is the decoded welcome message... database.participantDao().upsert(
// TODO: Sync GroupEvents/Messages... participants
database.chatMessageDao().upsert(
ChatMessage(
giftWrapPayloadId = giftWrapPayload.id,
senderPublicKey = giftWrapPayload.publicKey,
isUserMessage = activeKeyPair.pubKey.toHex() == giftWrapPayload.publicKey,
chatRoomId = chatRoomId,
createdAt = giftWrapPayload.createdAt,
content = "Coming soon.", // TODO: Figure out what to do here...
)
) )
participants.filter { it.participantPublicKey != userPublicKey }.forEach { participant ->
val publicKey = participant.participantPublicKey
val chatMessageRelayListEvent = database.nostrEventDao().getAuthoredNostrEvents(
kinds = arrayOf(
ChatMessageRelayListEvent.KIND
),
authors = arrayOf(
publicKey
),
since = GENESIS_AT,
limit = 5
).firstOrNull()?.let { nostrEvent ->
if (nostrEvent.kind != ChatMessageRelayListEvent.KIND) {
logger.e("getAuthoredNostrEvents returned invalid ChatMessageRelayListEvent for ${publicKey}: $nostrEvent")
null
} else {
ChatMessageRelayListEvent(
id = nostrEvent.id,
tags = nostrEvent.tags,
pubKey = nostrEvent.pubKey,
content = nostrEvent.content,
createdAt = nostrEvent.createdAt.epochSeconds,
sig = nostrEvent.sig
)
}
}
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 {
logger.w("We don't have a chatMessageRelayListEvent for the pubkey $publicKey")
// Sync ChatMessageRelayListEvent publicKey...
// TODO: Get relayHint form participant...
if (profilePublicKeysToSync.containsKey(relayURL)) {
profilePublicKeysToSync[relayURL] = mutableSetOf()
}
profilePublicKeysToSync[relayURL]?.add(participant.participantPublicKey)
}
}
} }
// Where is the decoded welcome message...
// TODO: Sync GroupEvents/Messages...
database.chatMessageDao().upsert(
ChatMessage(
giftWrapPayloadId = decryptedGiftWrapPayload.id,
senderPublicKey = decryptedGiftWrapPayload.publicKey,
isUserMessage = activeKeyPair.pubKey.toHex() == decryptedGiftWrapPayload.publicKey,
chatRoomId = nostrGroupId,
createdAt = decryptedGiftWrapPayload.createdAt,
content = "Coming soon.", // TODO: Figure out what to do here...
)
)
// TODO: Check encoding... // TODO: Check encoding...
} else { } else {
logger.w("Unsupported event: $giftWrapPayload") logger.w("Unsupported event: $decryptedGiftWrapPayload")
} }
} }
} }
@@ -7,6 +7,7 @@ import at.torch.compose.database.model.traits.LocalStoreEntity
import at.torch.compose.database.model.traits.SoftDeletableEntity import at.torch.compose.database.model.traits.SoftDeletableEntity
import at.torch.compose.database.model.traits.TimestampedEntity import at.torch.compose.database.model.traits.TimestampedEntity
import at.torch.compose.database.model.traits.UserViewableEntity import at.torch.compose.database.model.traits.UserViewableEntity
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
@@ -33,7 +34,7 @@ data class ChatMessage(
val senderPublicKey: String, val senderPublicKey: String,
val isUserMessage: Boolean, val isUserMessage: Boolean,
val giftWrapPayloadId: Long, val giftWrapPayloadId: HexKey,
val chatRoomId: String, val chatRoomId: String,
val replyToMessageId: Long? = null, val replyToMessageId: Long? = null,
@@ -67,7 +67,7 @@ data class ChatRoom(
// TODO: Add a LastChatMessageRelation // TODO: Add a LastChatMessageRelation
// val lastChatMessageId: String? = null, // val lastChatMessageId: String? = null,
// Might want to focus on the other last messages... // Might want to focus on the other last messages...
val initialGiftWrapPayloadId: Long? = null, val initialGiftWrapPayloadId: HexKey? = null,
override val createdAt: Instant = Clock.System.now(), override val createdAt: Instant = Clock.System.now(),
override val updatedAt: Instant = createdAt, override val updatedAt: Instant = createdAt,
@@ -5,6 +5,7 @@ import androidx.room3.ForeignKey
import androidx.room3.Ignore import androidx.room3.Ignore
import androidx.room3.PrimaryKey import androidx.room3.PrimaryKey
import co.touchlab.kermit.Logger 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.Kind
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
@@ -24,8 +25,8 @@ import kotlin.time.Instant
], ],
) )
data class GiftWrapPayload( // TODO: Rename this to GiftWrapPayload... data class GiftWrapPayload( // TODO: Rename this to GiftWrapPayload...
@PrimaryKey(autoGenerate = true) @PrimaryKey
val id: Long = 0, val id: HexKey,
/** /**
* Sender Public Key * Sender Public Key
@@ -34,7 +35,7 @@ data class GiftWrapPayload( // TODO: Rename this to GiftWrapPayload...
val chatRoomId: String, val chatRoomId: String,
/** /**`
* Payload Event Kind * Payload Event Kind
*/ */
val kind: Kind, val kind: Kind,
@@ -94,6 +94,7 @@ data class GiftWrapSeal(
logger.d("giftWrapPayload: ${giftWrapPayload.toJson()}") logger.d("giftWrapPayload: ${giftWrapPayload.toJson()}")
val giftWrapPayload = GiftWrapPayload( val giftWrapPayload = GiftWrapPayload(
id = id,
giftWrapSealId = id, giftWrapSealId = id,
publicKey = giftWrapPayload.pubKey, publicKey = giftWrapPayload.pubKey,
kind = giftWrapPayload.kind, kind = giftWrapPayload.kind,
@@ -22,8 +22,8 @@ import kotlin.time.Instant
] ]
) )
data class MarmotKeyPackage( data class MarmotKeyPackage(
@PrimaryKey(autoGenerate = true) @PrimaryKey
val id: Long = 0, val id: HexKey,
val publicKey: HexKey, val publicKey: HexKey,
@@ -26,13 +26,13 @@ import kotlin.time.Instant
], ],
) )
data class MarmotKeyPackageBundle( data class MarmotKeyPackageBundle(
@PrimaryKey(autoGenerate = true) @PrimaryKey
val id: Long = 0, val id: HexKey,
val publicKey: HexKey, val publicKey: HexKey,
// TODO: have the keyPackageEventId
val tlsEncodedMarmotKeyPackage: String, val tlsEncodedMarmotKeyPackage: ByteArray,
val ncryptsecInitPrivateKey: String, val ncryptsecInitPrivateKey: String,
val ncryptsecEncryptionPrivateKey: String, val ncryptsecEncryptionPrivateKey: String,
val ncryptsecSignaturePrivateKey: String, val ncryptsecSignaturePrivateKey: String,
@@ -45,4 +45,44 @@ data class MarmotKeyPackageBundle(
override val savedAt: Instant = Clock.System.now(), override val savedAt: Instant = Clock.System.now(),
override val deletedAt: Instant? = null, override val deletedAt: Instant? = null,
override val broadcastedAt: Instant? = null, override val broadcastedAt: Instant? = null,
): TimestampedEntity, LocalStoreEntity, BroadcastableEntity, SoftDeletableEntity ): TimestampedEntity, LocalStoreEntity, BroadcastableEntity, SoftDeletableEntity {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
other as MarmotKeyPackageBundle
if (consumed != other.consumed) return false
if (rotated != other.rotated) return false
if (id != other.id) return false
if (publicKey != other.publicKey) return false
if (!tlsEncodedMarmotKeyPackage.contentEquals(other.tlsEncodedMarmotKeyPackage)) return false
if (ncryptsecInitPrivateKey != other.ncryptsecInitPrivateKey) return false
if (ncryptsecEncryptionPrivateKey != other.ncryptsecEncryptionPrivateKey) return false
if (ncryptsecSignaturePrivateKey != other.ncryptsecSignaturePrivateKey) return false
if (createdAt != other.createdAt) return false
if (updatedAt != other.updatedAt) return false
if (savedAt != other.savedAt) return false
if (deletedAt != other.deletedAt) return false
if (broadcastedAt != other.broadcastedAt) return false
return true
}
override fun hashCode(): Int {
var result = consumed.hashCode()
result = 31 * result + rotated.hashCode()
result = 31 * result + id.hashCode()
result = 31 * result + publicKey.hashCode()
result = 31 * result + tlsEncodedMarmotKeyPackage.contentHashCode()
result = 31 * result + ncryptsecInitPrivateKey.hashCode()
result = 31 * result + ncryptsecEncryptionPrivateKey.hashCode()
result = 31 * result + ncryptsecSignaturePrivateKey.hashCode()
result = 31 * result + createdAt.hashCode()
result = 31 * result + updatedAt.hashCode()
result = 31 * result + savedAt.hashCode()
result = 31 * result + (deletedAt?.hashCode() ?: 0)
result = 31 * result + (broadcastedAt?.hashCode() ?: 0)
return result
}
}
@@ -212,6 +212,7 @@ data class NostrEvent(
} }
return null return null
} catch (e: Throwable) { } catch (e: Throwable) {
logger.e("GiftWrapMessage error: ", e)
return null return null
} }
@@ -1,9 +1,12 @@
package at.torch.compose.database.repository package at.torch.compose.database.repository
import at.torch.compose.database.model.ChatMessage
import at.torch.compose.database.model.GiftWrapPayload
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.core.Kind
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
@@ -14,6 +17,8 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlin.time.Clock
import kotlin.time.Instant
class DatabaseChatRepository( class DatabaseChatRepository(
private val database: at.torch.compose.database.TorchDatabase, private val database: at.torch.compose.database.TorchDatabase,
@@ -104,17 +109,28 @@ class DatabaseChatRepository(
) )
} }
val giftWrapPayloadId = database.giftWrapPayloadDao().upsert( val createdAt = Clock.System.now().epochSeconds
_root_ide_package_.at.torch.compose.database.model.GiftWrapPayload( val giftWrapPayloadId = EventHasher.hashId(
pubKey = localChatRoom.chatRoom.userPublicKey,
createdAt = createdAt,
tags = receiverTags.toTypedArray(),
content = text,
kind = messageType
)
database.giftWrapPayloadDao().upsert(
GiftWrapPayload(
id = giftWrapPayloadId,
kind = messageType, kind = messageType,
tags = receiverTags.toTypedArray(), tags = receiverTags.toTypedArray(),
createdAt = Instant.fromEpochSeconds(createdAt),
content = text, content = text,
chatRoomId = localChatRoom.chatRoom.id, chatRoomId = localChatRoom.chatRoom.id,
publicKey = localChatRoom.chatRoom.userPublicKey publicKey = localChatRoom.chatRoom.userPublicKey
) )
) )
database.chatMessageDao().upsert( database.chatMessageDao().upsert(
_root_ide_package_.at.torch.compose.database.model.ChatMessage( ChatMessage(
content = text, content = text,
chatRoomId = localChatRoom.chatRoom.id, chatRoomId = localChatRoom.chatRoom.id,
senderPublicKey = localChatRoom.chatRoom.userPublicKey, senderPublicKey = localChatRoom.chatRoom.userPublicKey,
@@ -2,13 +2,31 @@ package at.torch.compose.database.repository
import at.torch.compose.database.TorchDatabase import at.torch.compose.database.TorchDatabase
import at.torch.compose.database.model.MarmotKeyPackageBundle import at.torch.compose.database.model.MarmotKeyPackageBundle
import at.torch.compose.database.model.UnsignedNostrEvent
import at.torch.compose.extensions.toHex import at.torch.compose.extensions.toHex
import at.torch.compose.nostr.Relays
import at.torch.compose.repository.MarmotRepository import at.torch.compose.repository.MarmotRepository
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager.Companion.KEY_PACKAGE_LIFETIME_SECONDS
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageUtils
import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter
import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519
import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider
import com.vitorpamplona.quartz.marmot.mls.crypto.X25519
import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle
import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage
import com.vitorpamplona.quartz.marmot.mls.tree.Capabilities
import com.vitorpamplona.quartz.marmot.mls.tree.Credential
import com.vitorpamplona.quartz.marmot.mls.tree.Extension
import com.vitorpamplona.quartz.marmot.mls.tree.LeafNode
import com.vitorpamplona.quartz.marmot.mls.tree.LeafNodeSource
import com.vitorpamplona.quartz.marmot.mls.tree.Lifetime
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49 import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlin.io.encoding.Base64
class DatabaseMarmotRepository( class DatabaseMarmotRepository(
val database: TorchDatabase, val database: TorchDatabase,
@@ -16,24 +34,141 @@ class DatabaseMarmotRepository(
): MarmotRepository { ): MarmotRepository {
override suspend fun saveMarmotKeyPackageBundle( override suspend fun saveMarmotKeyPackageBundle(
publicKey: HexKey, publicKey: HexKey,
keyPackageBundle: KeyPackageBundle,
nsecPassword: String nsecPassword: String
) { ) {
val tlsWriter = TlsWriter() // KeyPackage...
val dTag = KeyPackageUtils.generateRandomDTag() // TODO: Persist this...
val identity = publicKey.hexToByteArray()
val bundle = generateKeyPackage(identity, dTag)
keyPackageBundle.keyPackage.encodeTls(tlsWriter) val keyPackageBytes = bundle.keyPackage.toTlsBytes()
val keyPackageBase64 = Base64.encode(keyPackageBytes)
val keyPackageRef = bundle.keyPackage.reference().toHexKey()
val nip49 = Nip49() val keyPackageTemplate = KeyPackageEvent.build(
keyPackageBase64 = keyPackageBase64,
dTagSlot = dTag,
keyPackageRef = keyPackageRef,
relays = Relays.DefaultDMRelayList,
)
database.marmotKeyPackageBundleDao().upsert( database.unsignedNostrEventDao().upsert(
MarmotKeyPackageBundle( UnsignedNostrEvent(
publicKey = publicKey, pubKey = publicKey,
tlsEncodedMarmotKeyPackage = tlsWriter.toByteArray().toHex(), kind = keyPackageTemplate.kind,
ncryptsecInitPrivateKey = keyPackageBundle.initPrivateKey.toHex(), // TODO: ncryptSec this using the private key tags = keyPackageTemplate.tags,
ncryptsecEncryptionPrivateKey = keyPackageBundle.encryptionPrivateKey.toHex(), // TODO: ncryptSec this using the private key content = keyPackageTemplate.content
ncryptsecSignaturePrivateKey = keyPackageBundle.signaturePrivateKey.toHex() // TODO: ncryptSec this using the private key
) )
) )
val id = EventHasher.hashIdBytes(
pubKey =publicKey,
createdAt = keyPackageTemplate.createdAt,
kind = keyPackageTemplate.kind,
tags = keyPackageTemplate.tags,
content = keyPackageTemplate.content
)
database.marmotKeyPackageBundleDao().upsert(
MarmotKeyPackageBundle(
id = id.toHexKey(),
publicKey = publicKey,
tlsEncodedMarmotKeyPackage = bundle.keyPackage.toTlsBytes(),
ncryptsecInitPrivateKey = bundle.initPrivateKey.toHex(), // TODO: ncryptSec this using the private key
ncryptsecEncryptionPrivateKey = bundle.encryptionPrivateKey.toHex(), // TODO: ncryptSec this using the private key
ncryptsecSignaturePrivateKey = bundle.signaturePrivateKey.toHex() // TODO: ncryptSec this using the private key
)
)
} }
private fun generateKeyPackage(
identity: ByteArray,
dTagSlot: String = KeyPackageUtils.PRIMARY_SLOT,
): KeyPackageBundle {
val initKp = X25519.generateKeyPair()
val encKp = X25519.generateKeyPair()
val sigKp = Ed25519.generateKeyPair()
val leafNode =
buildKeyPackageLeafNode(
encryptionKey = encKp.publicKey,
signatureKey = sigKp.publicKey,
identity = identity,
signingKey = sigKp.privateKey,
)
val unsigned =
MlsKeyPackage(
initKey = initKp.publicKey,
leafNode = leafNode,
// LastResort (0x000A) marks this as a last-resort KeyPackage per MLS Extensions draft.
// MDK always marks KeyPackages as last-resort via .mark_as_last_resort().
extensions = listOf(
Extension(
extensionType = 0x000A,
extensionData = ByteArray(0)
)
),
signature = ByteArray(0),
)
val keyPackage =
unsigned.copy(
signature =
MlsCryptoProvider.signWithLabel(
sigKp.privateKey,
"KeyPackageTBS",
unsigned.encodeTbs(),
),
)
val bundle = KeyPackageBundle(
keyPackage,
initPrivateKey = initKp.privateKey,
encryptionPrivateKey = encKp.privateKey,
signaturePrivateKey = sigKp.privateKey
)
return bundle
}
private fun buildKeyPackageLeafNode(
encryptionKey: ByteArray,
signatureKey: ByteArray,
identity: ByteArray,
signingKey: ByteArray,
): LeafNode {
val now = TimeUtils.now()
val unsigned =
LeafNode(
encryptionKey = encryptionKey,
signatureKey = signatureKey,
credential = Credential.Basic(identity),
capabilities =
Capabilities(
extensions =
listOf(
0x000A, // LastResort (required by OpenMLS validation)
0xF2EE, // NostrGroupData (required by group's RequiredCapabilities)
),
proposals =
listOf(
0x000A, // SelfRemove (required by group's RequiredCapabilities)
),
),
leafNodeSource = LeafNodeSource.KEY_PACKAGE,
lifetime = Lifetime(
notBefore = now,
notAfter = now + KEY_PACKAGE_LIFETIME_SECONDS
),
extensions = emptyList(),
signature = ByteArray(0),
)
val tbs = unsigned.encodeTbs(groupId = null, leafIndex = null)
val signature = MlsCryptoProvider.signWithLabel(signingKey, "LeafNodeTBS", tbs)
return unsigned.copy(signature = signature)
}
} }
@@ -114,7 +114,7 @@ class DatabaseNostrRepository(
publicKey: HexKey, publicKey: HexKey,
name: String?, name: String?,
biography: String? biography: String?
): KeyPackageBundle { ) {
val profileEventTemplate = MetadataEvent.createNew( val profileEventTemplate = MetadataEvent.createNew(
name = name, name = name,
about = biography, about = biography,
@@ -202,113 +202,6 @@ class DatabaseNostrRepository(
content = "" content = ""
) )
) )
// KeyPackage...
KeyPackageUtils.PRIMARY_SLOT
val dTag = KeyPackageUtils.generateRandomDTag() // TODO: Persist this...
val identity = publicKey.hexToByteArray()
val bundle = generateKeyPackage(identity, dTag)
val keyPackageBytes = bundle.keyPackage.toTlsBytes()
val keyPackageBase64 = Base64.encode(keyPackageBytes)
val keyPackageRef = bundle.keyPackage.reference().toHexKey()
val keyPackageTemplate = KeyPackageEvent.build(
keyPackageBase64 = keyPackageBase64,
dTagSlot = dTag,
keyPackageRef = keyPackageRef,
relays = Relays.DefaultDMRelayList,
)
unsignedNostrEvents.add(
UnsignedNostrEvent(
pubKey = publicKey,
kind = keyPackageTemplate.kind,
tags = keyPackageTemplate.tags,
content = keyPackageTemplate.content
)
)
saveUnsignedNostrEvents(unsignedNostrEvents)
return bundle
}
private fun generateKeyPackage(
identity: ByteArray,
dTagSlot: String = KeyPackageUtils.PRIMARY_SLOT,
): KeyPackageBundle {
val initKp = X25519.generateKeyPair()
val encKp = X25519.generateKeyPair()
val sigKp = Ed25519.generateKeyPair()
val leafNode =
buildKeyPackageLeafNode(
encryptionKey = encKp.publicKey,
signatureKey = sigKp.publicKey,
identity = identity,
signingKey = sigKp.privateKey,
)
val unsigned =
MlsKeyPackage(
initKey = initKp.publicKey,
leafNode = leafNode,
// LastResort (0x000A) marks this as a last-resort KeyPackage per MLS Extensions draft.
// MDK always marks KeyPackages as last-resort via .mark_as_last_resort().
extensions = listOf(Extension(extensionType = 0x000A, extensionData = ByteArray(0))),
signature = ByteArray(0),
)
val keyPackage =
unsigned.copy(
signature =
MlsCryptoProvider.signWithLabel(
sigKp.privateKey,
"KeyPackageTBS",
unsigned.encodeTbs(),
),
)
val bundle = KeyPackageBundle(keyPackage, initKp.privateKey, encKp.privateKey, sigKp.privateKey)
// TODO: Persist this...
return bundle
}
private fun buildKeyPackageLeafNode(
encryptionKey: ByteArray,
signatureKey: ByteArray,
identity: ByteArray,
signingKey: ByteArray,
): LeafNode {
val now = TimeUtils.now()
val unsigned =
LeafNode(
encryptionKey = encryptionKey,
signatureKey = signatureKey,
credential = Credential.Basic(identity),
capabilities =
Capabilities(
extensions =
listOf(
0x000A, // LastResort (required by OpenMLS validation)
0xF2EE, // NostrGroupData (required by group's RequiredCapabilities)
),
proposals =
listOf(
0x000A, // SelfRemove (required by group's RequiredCapabilities)
),
),
leafNodeSource = LeafNodeSource.KEY_PACKAGE,
lifetime = Lifetime(notBefore = now, notAfter = now + KEY_PACKAGE_LIFETIME_SECONDS),
extensions = emptyList(),
signature = ByteArray(0),
)
val tbs = unsigned.encodeTbs(groupId = null, leafIndex = null)
val signature = MlsCryptoProvider.signWithLabel(signingKey, "LeafNodeTBS", tbs)
return unsigned.copy(signature = signature)
} }
override suspend fun signInToProfile( override suspend fun signInToProfile(
@@ -0,0 +1,4 @@
package at.torch.compose.exceptions
class MarmotMissingKeyPackageBundleException(message: String? = null, cause: Throwable? = null) : Exception(message, cause) {
}
@@ -1,4 +0,0 @@
package at.torch.compose.exceptions
class MarmotNoMatchingKeyPackageBundleException(message: String? = null, cause: Throwable? = null) : Exception(message, cause) {
}
@@ -0,0 +1,4 @@
package at.torch.compose.exceptions
class MarmotWelcomeEventMissingKeyPackageEventIdException(message: String? = null, cause: Throwable? = null) : Exception(message, cause) {
}
@@ -0,0 +1,4 @@
package at.torch.compose.exceptions
class MarmotWelcomeMissingGroupIdException(message: String? = null, cause: Throwable? = null) : Exception(message, cause) {
}
@@ -1,13 +1,11 @@
package at.torch.compose.repository package at.torch.compose.repository
import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
interface MarmotRepository { interface MarmotRepository {
suspend fun saveMarmotKeyPackageBundle( suspend fun saveMarmotKeyPackageBundle(
publicKey: HexKey, publicKey: HexKey,
keyPackageBundle: KeyPackageBundle,
nsecPassword: String nsecPassword: String
) )
@@ -15,7 +13,6 @@ interface MarmotRepository {
val NO_OP_MARMOT_KEY_PACKAGE_BUNDLE = object: MarmotRepository { val NO_OP_MARMOT_KEY_PACKAGE_BUNDLE = object: MarmotRepository {
override suspend fun saveMarmotKeyPackageBundle( override suspend fun saveMarmotKeyPackageBundle(
publicKey: HexKey, publicKey: HexKey,
keyPackageBundle: KeyPackageBundle,
nsecPassword: String nsecPassword: String
) { ) {
TODO("Not yet implemented") TODO("Not yet implemented")
@@ -1,7 +1,6 @@
package at.torch.compose.repository package at.torch.compose.repository
import at.torch.compose.database.model.intermdiate.LocalAccount import at.torch.compose.database.model.intermdiate.LocalAccount
import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.core.Kind
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
@@ -28,7 +27,7 @@ interface NostrRepository {
publicKey: HexKey, publicKey: HexKey,
name: String?, name: String?,
biography: String? biography: String?
): KeyPackageBundle )
suspend fun signInToProfile( suspend fun signInToProfile(
publicKey: HexKey publicKey: HexKey
@@ -163,7 +162,7 @@ interface NostrRepository {
publicKey: HexKey, publicKey: HexKey,
name: String?, name: String?,
biography: String? biography: String?
): KeyPackageBundle { ) {
TODO("") TODO("")
} }
@@ -287,7 +287,7 @@ private fun ChatRoomDetailScreenPreview() {
id = "", id = "",
userPublicKey = "", userPublicKey = "",
subject = "Message title", subject = "Message title",
initialGiftWrapPayloadId = 0, initialGiftWrapPayloadId = "sdfaer",
mlsGroupState = null mlsGroupState = null
), ),
) )
@@ -25,6 +25,7 @@ import at.torch.compose.ui.composable.navigation.routes.ChatRoomCreationRoute
import at.torch.compose.ui.composable.navigation.routes.LoadingRoute import at.torch.compose.ui.composable.navigation.routes.LoadingRoute
import at.torch.compose.ui.composable.navigation.routes.NostrEventDetailRoute import at.torch.compose.ui.composable.navigation.routes.NostrEventDetailRoute
import at.torch.compose.ui.composable.navigation.routes.ShareProfileRoute import at.torch.compose.ui.composable.navigation.routes.ShareProfileRoute
import at.torch.compose.ui.composable.navigation.routes.SocialPreconditionRoute
import at.torch.compose.ui.composable.navigation.routes.SovereignWalletStartupRoute import at.torch.compose.ui.composable.navigation.routes.SovereignWalletStartupRoute
import at.torch.compose.ui.composable.navigation.routes.WriteNewNoteRoute import at.torch.compose.ui.composable.navigation.routes.WriteNewNoteRoute
import at.torch.compose.ui.view.state.NavigationUIState import at.torch.compose.ui.view.state.NavigationUIState
@@ -101,6 +102,7 @@ fun TorchNavHost(
scope = applicationIOScope scope = applicationIOScope
) )
) )
// TODO: Produce a notary UI Element... // TODO: Produce a notary UI Element...
val synchronizationViewModel: at.torch.compose.ui.view.model.SynchronizationViewModel = viewModel( val synchronizationViewModel: at.torch.compose.ui.view.model.SynchronizationViewModel = viewModel(
factory = at.torch.compose.ui.view.model.SynchronizationViewModel.factory( factory = at.torch.compose.ui.view.model.SynchronizationViewModel.factory(
@@ -203,7 +205,7 @@ fun TorchNavHost(
} }
is NavigationUIState.ProfileLoaded -> { is NavigationUIState.ProfileLoaded -> {
navController.navigate( navController.navigate(
route = at.torch.compose.ui.composable.navigation.routes.SocialPreconditionRoute( route = SocialPreconditionRoute(
activeUserPubkey = state.publicKey activeUserPubkey = state.publicKey
) )
) { ) {
@@ -339,7 +341,7 @@ fun TorchNavHost(
}, },
onNavigateToNostrEvent = { hexKey -> onNavigateToNostrEvent = { hexKey ->
navController.navigate( navController.navigate(
route = at.torch.compose.ui.composable.navigation.routes.NostrEventDetailRoute( route = NostrEventDetailRoute(
activeUserPublicKey = route.activeUserPublicKey, activeUserPublicKey = route.activeUserPublicKey,
nostrEventId = hexKey nostrEventId = hexKey
) )
@@ -383,8 +385,8 @@ fun TorchNavHost(
at.torch.compose.ui.composable.UnannouncedProfileScreen() at.torch.compose.ui.composable.UnannouncedProfileScreen()
} }
composable<at.torch.compose.ui.composable.navigation.routes.SocialPreconditionRoute> { backStackEntry -> composable<SocialPreconditionRoute> { backStackEntry ->
val route = backStackEntry.toRoute<at.torch.compose.ui.composable.navigation.routes.SocialPreconditionRoute>() val route = backStackEntry.toRoute<SocialPreconditionRoute>()
at.torch.compose.ui.composable.SocialPreconditionScreen( at.torch.compose.ui.composable.SocialPreconditionScreen(
onNavigateToSkipForNow = { onNavigateToSkipForNow = {
navController.navigate( navController.navigate(
@@ -105,7 +105,8 @@ class CreateProfileViewModel(
) )
val pubkey = localKeyManager.nostrPublicKey() val pubkey = localKeyManager.nostrPublicKey()
val keyPackageBundle = nostrRepository.createNewProfile(
nostrRepository.createNewProfile(
pubkey, pubkey,
name = createProfileFormState.nameField.textFieldState.text.toString(), name = createProfileFormState.nameField.textFieldState.text.toString(),
biography = createProfileFormState.biographyField.textFieldState.text.toString() biography = createProfileFormState.biographyField.textFieldState.text.toString()
@@ -113,7 +114,6 @@ class CreateProfileViewModel(
marmotRepository.saveMarmotKeyPackageBundle( marmotRepository.saveMarmotKeyPackageBundle(
publicKey = pubkey, publicKey = pubkey,
keyPackageBundle,
nsecPassword = localKeyManager.nsecPassword() nsecPassword = localKeyManager.nsecPassword()
) )
} }
@@ -54,7 +54,7 @@ class NotaryViewModel(
init { init {
scope.launch { scope.launch {
activeWalletStateFlow.collectLatest { activeWallet -> activeWalletStateFlow.collectLatest { activeWallet ->
logger.d("activeWallet: $activeWallet") logger.d("Notary ActiveWallet: $activeWallet")
activeWallet?.business?.walletManager?.keyManager?.value?.nostrPrivateKey()?.let { nostrPrivateKey -> activeWallet?.business?.walletManager?.keyManager?.value?.nostrPrivateKey()?.let { nostrPrivateKey ->
val keyPair = KeyPair( val keyPair = KeyPair(
@@ -74,6 +74,7 @@ class SynchronizationViewModel(
init { init {
scope.launch { scope.launch {
activeWalletStateFlow.collectLatest { activeWallet -> activeWalletStateFlow.collectLatest { activeWallet ->
logger.d("Synchronization ActiveWallet: $activeWallet")
activeWallet?.business?.walletManager?.keyManager?.value?.nostrPrivateKey()?.let { nostrPrivateKey -> activeWallet?.business?.walletManager?.keyManager?.value?.nostrPrivateKey()?.let { nostrPrivateKey ->
val keyPair = KeyPair( val keyPair = KeyPair(
privKey = nostrPrivateKey.value.toByteArray() privKey = nostrPrivateKey.value.toByteArray()
+1 -1
View File
@@ -30,7 +30,7 @@ okhttp = "5.3.2"
okio = "3.17.0" okio = "3.17.0"
pagingCommon = "3.5.0" pagingCommon = "3.5.0"
qrose = "1.1.2" qrose = "1.1.2"
quartz = "1.10.0" quartz = "1.12.6"
room3 = "3.0.0-alpha06" room3 = "3.0.0-alpha06"
sqldelight = "2.3.2" sqldelight = "2.3.2"
sqlite = "2.6.2" sqlite = "2.6.2"