Add deterministic id on MLS related models

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

View File

@@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "f33937ebefe679fe03b71b3930472139",
"identityHash": "8dd191b21e518a19f39b50a5f5b4a432",
"entities": [
{
"tableName": "BroadcastNostrEventReceipt",
@@ -282,7 +282,7 @@
},
{
"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": [
{
"fieldPath": "id",
@@ -305,7 +305,7 @@
{
"fieldPath": "giftWrapPayloadId",
"columnName": "giftWrapPayloadId",
"affinity": "INTEGER",
"affinity": "TEXT",
"notNull": true
},
{
@@ -608,7 +608,7 @@
},
{
"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": [
{
"fieldPath": "id",
@@ -635,7 +635,7 @@
{
"fieldPath": "initialGiftWrapPayloadId",
"columnName": "initialGiftWrapPayloadId",
"affinity": "INTEGER"
"affinity": "TEXT"
},
{
"fieldPath": "createdAt",
@@ -893,12 +893,12 @@
},
{
"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": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"affinity": "TEXT",
"notNull": true
},
{
@@ -971,7 +971,7 @@
}
],
"primaryKey": {
"autoGenerate": true,
"autoGenerate": false,
"columnNames": [
"id"
]
@@ -1117,12 +1117,12 @@
},
{
"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": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"affinity": "TEXT",
"notNull": true
},
{
@@ -1167,7 +1167,7 @@
}
],
"primaryKey": {
"autoGenerate": true,
"autoGenerate": false,
"columnNames": [
"id"
]
@@ -1188,12 +1188,12 @@
},
{
"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": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"affinity": "TEXT",
"notNull": true
},
{
@@ -1205,7 +1205,7 @@
{
"fieldPath": "tlsEncodedMarmotKeyPackage",
"columnName": "tlsEncodedMarmotKeyPackage",
"affinity": "TEXT",
"affinity": "BLOB",
"notNull": true
},
{
@@ -1268,7 +1268,7 @@
}
],
"primaryKey": {
"autoGenerate": true,
"autoGenerate": false,
"columnNames": [
"id"
]
@@ -3044,7 +3044,7 @@
],
"setupQueries": [
"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')"
]
}
}

View File

@@ -4,6 +4,7 @@ import androidx.room3.Dao
import androidx.room3.Query
import androidx.room3.Transaction
import androidx.room3.Upsert
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.flow.Flow
@Dao
@@ -17,7 +18,7 @@ interface ChatMessageDao {
fun getChatMessagesByChatRoomId(chatRoomId: String): List<at.torch.compose.database.model.intermdiate.LocalChatMessage>
@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
suspend fun upsert(chatMessage: at.torch.compose.database.model.ChatMessage): Long

View File

@@ -14,5 +14,5 @@ interface GiftWrapPayloadDao {
fun observeUnsealedGiftWrapPayloads(publicKey: String): Flow<at.torch.compose.database.model.GiftWrapPayload?>
@Upsert
suspend fun upsert(giftWrapPayload: at.torch.compose.database.model.GiftWrapPayload): Long
suspend fun upsert(giftWrapPayload: at.torch.compose.database.model.GiftWrapPayload)
}

View File

@@ -7,8 +7,8 @@ import at.torch.compose.database.model.MarmotKeyPackageBundle
@Dao
interface MarmotKeyPackageBundleDao {
@Query("SELECT * FROM MarmotKeyPackageBundle WHERE id = :keyPackageEventId ORDER BY createdAt DESC")
fun getMarmotKeyPackageBundleByKeyPackageEventIdId(keyPackageEventId: String): MarmotKeyPackageBundle?
@Query("SELECT * FROM MarmotKeyPackageBundle WHERE id = :id ORDER BY createdAt DESC")
fun getMarmotKeyPackageBundleById(id: String): MarmotKeyPackageBundle?
@Upsert
suspend fun upsert(marmotKeyPackageBundle: MarmotKeyPackageBundle)

View File

@@ -18,10 +18,8 @@ import at.torch.compose.database.model.types.SynchronizationFilter
import at.torch.compose.exceptions.GiftWrapImpersonationException
import at.torch.compose.exceptions.GiftWrapSealDecryptionException
import at.torch.compose.exceptions.GiftWrapUnsealException
import at.torch.compose.exceptions.MarmotMissingKeyPackageEventIdException
import at.torch.compose.exceptions.MarmotMissingNostrGroupDataExtension
import at.torch.compose.exceptions.MarmotNoMatchingKeyPackageBundleException
import at.torch.compose.exceptions.MarmotWelcomeMismatchedGroupIdException
import at.torch.compose.exceptions.MarmotMissingKeyPackageBundleException
import at.torch.compose.exceptions.MarmotWelcomeEventMissingKeyPackageEventIdException
import at.torch.compose.extensions.toHex
import co.touchlab.kermit.Logger
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.people.taggedUsers
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
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}")
// Setup the sync here...
} else {
return
// TODO: Check if event got indexed...
// return
}
}
@@ -388,257 +388,397 @@ abstract class NostrDao(
logger.e("Impersonation", giftWrapImpersonation)
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
)
val giftWrapPayload = if (result != -1L) {
decryptedGiftWrapPayload.copy(
id = result
if (decryptedGiftWrapPayload.kind == ChatMessageEvent.KIND) {
logger.d("chatMessageEvent: $decryptedGiftWrapPayload")
// Handle normal nip-17 messages...
val localChatRoom = database.chatRoomDao().findChatRoomById(
decryptedGiftWrapPayload.chatRoomId
)
} else {
decryptedGiftWrapPayload
}
// TODO: Handle MLS message/groups
if (giftWrapPayload.kind == WelcomeEvent.KIND) {
logger.d("Welcome Event")
if (localChatRoom == null) {
val userPublicKey = activeKeyPair.pubKey.toHex()
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(
id = "", // TODO: Need the ID as string in decryptedGiftWrapPayload...
id = decryptedGiftWrapPayload.id, // TODO: Need the ID as string in decryptedGiftWrapPayload...
pubKey = decryptedGiftWrapPayload.publicKey,
content = decryptedGiftWrapPayload.content,
createdAt = decryptedGiftWrapPayload.createdAt.epochSeconds,
tags = decryptedGiftWrapPayload.tags,
sig = ""
)
logger.d("welcomeEvent: $welcomeEvent")
welcomeEvent.nostrGroupId()?.let { chatRoomId ->
val localChatRoom = database.chatRoomDao().findChatRoomById(
chatRoomId
// TODO: Find keypackage and decrypt welcome...
// TODO: Get keyPackage from
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) {
val welcomeBytes = Base64.decode(
welcomeEvent.welcomeBase64()
database.marmotKeyPackageBundleDao().upsert(
marmotKeyPackageBundle.copy(
consumed = true
)
val keyPackageEventId = welcomeEvent.keyPackageEventId()
if (keyPackageEventId == null) {
throw MarmotMissingKeyPackageEventIdException(
"WelcomeEvent ${welcomeEvent.id} missing KeyPackage event ID tag"
)
// 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 = 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
val marmotKeyPackageBundle = database.marmotKeyPackageBundleDao().getMarmotKeyPackageBundleByKeyPackageEventIdId(
keyPackageEventId
)
if (marmotKeyPackageBundle == null) {
// We were unable to find the keyPackageBundle for this invite...
throw MarmotNoMatchingKeyPackageBundleException(
"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
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 {
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)) {
if (profilePublicKeysToSync[relayURL] == null) {
profilePublicKeysToSync[relayURL] = mutableSetOf()
}
profilePublicKeysToSync[relayURL]?.add(participant.participantPublicKey)
profilePublicKeysToSync[relayURL]?.add(
participant.participantPublicKey
)
}
}
}
// Where is the decoded welcome message...
// TODO: Sync GroupEvents/Messages...
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...
)
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)
}
}
}
// 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...
} else {
logger.w("Unsupported event: $giftWrapPayload")
logger.w("Unsupported event: $decryptedGiftWrapPayload")
}
}
}

View File

@@ -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.TimestampedEntity
import at.torch.compose.database.model.traits.UserViewableEntity
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlin.time.Clock
import kotlin.time.Instant
@@ -33,7 +34,7 @@ data class ChatMessage(
val senderPublicKey: String,
val isUserMessage: Boolean,
val giftWrapPayloadId: Long,
val giftWrapPayloadId: HexKey,
val chatRoomId: String,
val replyToMessageId: Long? = null,

View File

@@ -67,7 +67,7 @@ data class ChatRoom(
// TODO: Add a LastChatMessageRelation
// val lastChatMessageId: String? = null,
// 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 updatedAt: Instant = createdAt,

View File

@@ -5,6 +5,7 @@ import androidx.room3.ForeignKey
import androidx.room3.Ignore
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.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
@@ -24,8 +25,8 @@ import kotlin.time.Instant
],
)
data class GiftWrapPayload( // TODO: Rename this to GiftWrapPayload...
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
@PrimaryKey
val id: HexKey,
/**
* Sender Public Key
@@ -34,7 +35,7 @@ data class GiftWrapPayload( // TODO: Rename this to GiftWrapPayload...
val chatRoomId: String,
/**
/**`
* Payload Event Kind
*/
val kind: Kind,

View File

@@ -94,6 +94,7 @@ data class GiftWrapSeal(
logger.d("giftWrapPayload: ${giftWrapPayload.toJson()}")
val giftWrapPayload = GiftWrapPayload(
id = id,
giftWrapSealId = id,
publicKey = giftWrapPayload.pubKey,
kind = giftWrapPayload.kind,

View File

@@ -22,8 +22,8 @@ import kotlin.time.Instant
]
)
data class MarmotKeyPackage(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
@PrimaryKey
val id: HexKey,
val publicKey: HexKey,

View File

@@ -26,13 +26,13 @@ import kotlin.time.Instant
],
)
data class MarmotKeyPackageBundle(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
@PrimaryKey
val id: HexKey,
val publicKey: HexKey,
// TODO: have the keyPackageEventId
val tlsEncodedMarmotKeyPackage: String,
val tlsEncodedMarmotKeyPackage: ByteArray,
val ncryptsecInitPrivateKey: String,
val ncryptsecEncryptionPrivateKey: String,
val ncryptsecSignaturePrivateKey: String,
@@ -45,4 +45,44 @@ data class MarmotKeyPackageBundle(
override val savedAt: Instant = Clock.System.now(),
override val deletedAt: 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
}
}

View File

@@ -212,6 +212,7 @@ data class NostrEvent(
}
return null
} catch (e: Throwable) {
logger.e("GiftWrapMessage error: ", e)
return null
}

View File

@@ -1,9 +1,12 @@
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 com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
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.relay.normalizer.NormalizedRelayUrl
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlin.time.Clock
import kotlin.time.Instant
class DatabaseChatRepository(
private val database: at.torch.compose.database.TorchDatabase,
@@ -104,17 +109,28 @@ class DatabaseChatRepository(
)
}
val giftWrapPayloadId = database.giftWrapPayloadDao().upsert(
_root_ide_package_.at.torch.compose.database.model.GiftWrapPayload(
val createdAt = Clock.System.now().epochSeconds
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,
tags = receiverTags.toTypedArray(),
createdAt = Instant.fromEpochSeconds(createdAt),
content = text,
chatRoomId = localChatRoom.chatRoom.id,
publicKey = localChatRoom.chatRoom.userPublicKey
)
)
database.chatMessageDao().upsert(
_root_ide_package_.at.torch.compose.database.model.ChatMessage(
ChatMessage(
content = text,
chatRoomId = localChatRoom.chatRoom.id,
senderPublicKey = localChatRoom.chatRoom.userPublicKey,

View File

@@ -2,13 +2,31 @@ package at.torch.compose.database.repository
import at.torch.compose.database.TorchDatabase
import at.torch.compose.database.model.MarmotKeyPackageBundle
import at.torch.compose.database.model.UnsignedNostrEvent
import at.torch.compose.extensions.toHex
import at.torch.compose.nostr.Relays
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.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.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.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 kotlin.io.encoding.Base64
class DatabaseMarmotRepository(
val database: TorchDatabase,
@@ -16,24 +34,141 @@ class DatabaseMarmotRepository(
): MarmotRepository {
override suspend fun saveMarmotKeyPackageBundle(
publicKey: HexKey,
keyPackageBundle: KeyPackageBundle,
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(
MarmotKeyPackageBundle(
publicKey = publicKey,
tlsEncodedMarmotKeyPackage = tlsWriter.toByteArray().toHex(),
ncryptsecInitPrivateKey = keyPackageBundle.initPrivateKey.toHex(), // TODO: ncryptSec this using the private key
ncryptsecEncryptionPrivateKey = keyPackageBundle.encryptionPrivateKey.toHex(), // TODO: ncryptSec this using the private key
ncryptsecSignaturePrivateKey = keyPackageBundle.signaturePrivateKey.toHex() // TODO: ncryptSec this using the private key
database.unsignedNostrEventDao().upsert(
UnsignedNostrEvent(
pubKey = publicKey,
kind = keyPackageTemplate.kind,
tags = keyPackageTemplate.tags,
content = keyPackageTemplate.content
)
)
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)
}
}

View File

@@ -114,7 +114,7 @@ class DatabaseNostrRepository(
publicKey: HexKey,
name: String?,
biography: String?
): KeyPackageBundle {
) {
val profileEventTemplate = MetadataEvent.createNew(
name = name,
about = biography,
@@ -202,113 +202,6 @@ class DatabaseNostrRepository(
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(

View File

@@ -0,0 +1,4 @@
package at.torch.compose.exceptions
class MarmotMissingKeyPackageBundleException(message: String? = null, cause: Throwable? = null) : Exception(message, cause) {
}

View File

@@ -1,4 +0,0 @@
package at.torch.compose.exceptions
class MarmotNoMatchingKeyPackageBundleException(message: String? = null, cause: Throwable? = null) : Exception(message, cause) {
}

View File

@@ -0,0 +1,4 @@
package at.torch.compose.exceptions
class MarmotWelcomeEventMissingKeyPackageEventIdException(message: String? = null, cause: Throwable? = null) : Exception(message, cause) {
}

View File

@@ -0,0 +1,4 @@
package at.torch.compose.exceptions
class MarmotWelcomeMissingGroupIdException(message: String? = null, cause: Throwable? = null) : Exception(message, cause) {
}

View File

@@ -1,13 +1,11 @@
package at.torch.compose.repository
import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle
import com.vitorpamplona.quartz.nip01Core.core.HexKey
interface MarmotRepository {
suspend fun saveMarmotKeyPackageBundle(
publicKey: HexKey,
keyPackageBundle: KeyPackageBundle,
nsecPassword: String
)
@@ -15,7 +13,6 @@ interface MarmotRepository {
val NO_OP_MARMOT_KEY_PACKAGE_BUNDLE = object: MarmotRepository {
override suspend fun saveMarmotKeyPackageBundle(
publicKey: HexKey,
keyPackageBundle: KeyPackageBundle,
nsecPassword: String
) {
TODO("Not yet implemented")

View File

@@ -1,7 +1,6 @@
package at.torch.compose.repository
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.Kind
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
@@ -28,7 +27,7 @@ interface NostrRepository {
publicKey: HexKey,
name: String?,
biography: String?
): KeyPackageBundle
)
suspend fun signInToProfile(
publicKey: HexKey
@@ -163,7 +162,7 @@ interface NostrRepository {
publicKey: HexKey,
name: String?,
biography: String?
): KeyPackageBundle {
) {
TODO("")
}

View File

@@ -287,7 +287,7 @@ private fun ChatRoomDetailScreenPreview() {
id = "",
userPublicKey = "",
subject = "Message title",
initialGiftWrapPayloadId = 0,
initialGiftWrapPayloadId = "sdfaer",
mlsGroupState = null
),
)

View File

@@ -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.NostrEventDetailRoute
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.WriteNewNoteRoute
import at.torch.compose.ui.view.state.NavigationUIState
@@ -101,6 +102,7 @@ fun TorchNavHost(
scope = applicationIOScope
)
)
// TODO: Produce a notary UI Element...
val synchronizationViewModel: at.torch.compose.ui.view.model.SynchronizationViewModel = viewModel(
factory = at.torch.compose.ui.view.model.SynchronizationViewModel.factory(
@@ -203,7 +205,7 @@ fun TorchNavHost(
}
is NavigationUIState.ProfileLoaded -> {
navController.navigate(
route = at.torch.compose.ui.composable.navigation.routes.SocialPreconditionRoute(
route = SocialPreconditionRoute(
activeUserPubkey = state.publicKey
)
) {
@@ -339,7 +341,7 @@ fun TorchNavHost(
},
onNavigateToNostrEvent = { hexKey ->
navController.navigate(
route = at.torch.compose.ui.composable.navigation.routes.NostrEventDetailRoute(
route = NostrEventDetailRoute(
activeUserPublicKey = route.activeUserPublicKey,
nostrEventId = hexKey
)
@@ -383,8 +385,8 @@ fun TorchNavHost(
at.torch.compose.ui.composable.UnannouncedProfileScreen()
}
composable<at.torch.compose.ui.composable.navigation.routes.SocialPreconditionRoute> { backStackEntry ->
val route = backStackEntry.toRoute<at.torch.compose.ui.composable.navigation.routes.SocialPreconditionRoute>()
composable<SocialPreconditionRoute> { backStackEntry ->
val route = backStackEntry.toRoute<SocialPreconditionRoute>()
at.torch.compose.ui.composable.SocialPreconditionScreen(
onNavigateToSkipForNow = {
navController.navigate(

View File

@@ -105,7 +105,8 @@ class CreateProfileViewModel(
)
val pubkey = localKeyManager.nostrPublicKey()
val keyPackageBundle = nostrRepository.createNewProfile(
nostrRepository.createNewProfile(
pubkey,
name = createProfileFormState.nameField.textFieldState.text.toString(),
biography = createProfileFormState.biographyField.textFieldState.text.toString()
@@ -113,7 +114,6 @@ class CreateProfileViewModel(
marmotRepository.saveMarmotKeyPackageBundle(
publicKey = pubkey,
keyPackageBundle,
nsecPassword = localKeyManager.nsecPassword()
)
}

View File

@@ -54,7 +54,7 @@ class NotaryViewModel(
init {
scope.launch {
activeWalletStateFlow.collectLatest { activeWallet ->
logger.d("activeWallet: $activeWallet")
logger.d("Notary ActiveWallet: $activeWallet")
activeWallet?.business?.walletManager?.keyManager?.value?.nostrPrivateKey()?.let { nostrPrivateKey ->
val keyPair = KeyPair(

View File

@@ -74,6 +74,7 @@ class SynchronizationViewModel(
init {
scope.launch {
activeWalletStateFlow.collectLatest { activeWallet ->
logger.d("Synchronization ActiveWallet: $activeWallet")
activeWallet?.business?.walletManager?.keyManager?.value?.nostrPrivateKey()?.let { nostrPrivateKey ->
val keyPair = KeyPair(
privKey = nostrPrivateKey.value.toByteArray()