Marmot ingestion and all

This commit is contained in:
Kgothatso Ngako
2026-06-23 13:02:54 +02:00
parent 552ab702b1
commit 5e93a39a6c
7 changed files with 371 additions and 16 deletions

View File

@@ -21,7 +21,7 @@ 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.MarmotWelcomeMismatchedGroupId
import at.torch.compose.exceptions.MarmotWelcomeMismatchedGroupIdException
import at.torch.compose.extensions.toHex
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle
import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage
import com.vitorpamplona.quartz.marmot.mls.tree.Credential
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
@@ -41,7 +42,6 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import kotlin.io.encoding.Base64
import kotlin.math.sign
import kotlin.time.Clock
import kotlin.time.Instant
@@ -324,6 +324,7 @@ abstract class NostrDao(
}
// TODO: Support MLSGroupEvent handling...
nostrEvent.toGiftWrapMessageWithReceiverPTag()?.let { giftWrapMessage ->
logger.d("giftWrapMessage: $giftWrapMessage")
val giftWrapReceiverProfile = database.profileDao().getProfileByPublicKey(giftWrapMessage.receiverPublicKey)
@@ -472,7 +473,7 @@ abstract class NostrDao(
}
if (derivedId != chatRoomId) {
throw MarmotWelcomeMismatchedGroupId(
throw MarmotWelcomeMismatchedGroupIdException(
"The hint group Id $chatRoomId doesn't match the derived group id $derivedId"
)
}
@@ -490,16 +491,30 @@ abstract class NostrDao(
)
)
// TODO: Add participants... by processing group.members()
val participants = giftWrapPayload.participantPTags(
NormalizedRelayUrl(relayURL) // TODO: Get relayURL for publicKey profile...
).map {
Participant(
participantPublicKey = it.pubKey,
chatRoomId = chatRoomId,
relayHint = it.relayHint?.url
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
)
} else {
null
}
}
// Sync missing participant profiles...

View File

@@ -0,0 +1,123 @@
package at.torch.compose.database.model
import androidx.room3.Entity
import androidx.room3.ForeignKey
import androidx.room3.PrimaryKey
import at.torch.compose.database.model.traits.BroadcastableEntity
import at.torch.compose.database.model.traits.LocalStoreEntity
import at.torch.compose.database.model.traits.NostrEventEntity
import at.torch.compose.database.model.traits.SoftDeletableEntity
import at.torch.compose.database.model.traits.TimestampedEntity
import at.torch.compose.exceptions.MarmotMissingRetainedExporterSecretsException
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.utils.Log
import kotlin.time.Clock
import kotlin.time.Instant
@Entity(
foreignKeys = [
],
)
data class MarmotGroupEvent(
@PrimaryKey
val id: HexKey,
/**
* Sender Public Key
*/
val userPublicKey: String,
val chatRoomId: String,
val encryptedContent: String,
val expiresAt: Instant? = null,
/**
* What gets broadcasts?
*/
override val nostrEventId: String,
override val createdAt: Instant = Clock.System.now(),
override val updatedAt: Instant = Clock.System.now(),
override val savedAt: Instant = Clock.System.now(),
override val deletedAt: Instant? = null,
override val broadcastedAt: Instant? = null,
) : NostrEventEntity, TimestampedEntity, LocalStoreEntity, BroadcastableEntity, SoftDeletableEntity {
companion object {
const val KIND = GroupEvent.KIND
fun fromGroupEvent(
groupEvent: GroupEvent,
userPublicKey: String
): MarmotGroupEvent? {
return groupEvent.groupId()?.let { chatRoomId ->
MarmotGroupEvent(
id = groupEvent.id,
encryptedContent = groupEvent.encryptedContent(),
nostrEventId = groupEvent.id,
createdAt = Instant.fromEpochSeconds(groupEvent.createdAt),
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
expiresAt = groupEvent.expiration()?.let { Instant.fromEpochSeconds(it) }
)
}
}
}
fun toMarmotInnerEvent(group: MlsGroup): MarmotInnerEvent? {
val mlsBytes =
return null;
}
/**
* Decrypt the outer ChaCha20-Poly1305 layer, trying the current epoch's
* exporter key first and falling back to retained epoch exporter keys.
*
* After a commit advances the epoch, late-arriving messages encrypted
* with the previous epoch's exporter key would fail without this fallback.
*
* Returns null when neither the current epoch key nor any retained key
* decrypts. This happens normally for commits/application messages from
* epochs that predate our join (we never held those keys), so callers
* should treat null as an expected "nothing to do here" outcome and log
* at DEBUG, not as an error.
*/
private fun tryDecryptOuterLayer(
group: MlsGroup,
encryptedContent: String,
): ByteArray? {
// Try current epoch key first
try {
val exporterKey = group.exporterSecret(
"marmot",
"group-event".encodeToByteArray(),
32,
)
return GroupEventEncryption.decrypt(encryptedContent, exporterKey)
} catch (e: Exception) {
// Current epoch key failed — try retained epoch keys
Log.e("MarmotGroupEvent", "Current Epoch Key Failed", e)
}
// Try retained epoch exporter keys (most recent first)
throw MarmotMissingRetainedExporterSecretsException(
"Torch currently doesn't support retainedExporterSecrets"
)
// val retainedKeys = groupManager.retainedExporterSecrets(groupId)
// for (retainedKey in retainedKeys) {
// try {
// return GroupEventEncryption.decrypt(encryptedContent, retainedKey)
// } catch (_: Exception) {
// // This retained key didn't work — try the next one
// }
// }
//
// return null
}
}

View File

@@ -0,0 +1,211 @@
package at.torch.compose.database.model
import androidx.room3.Entity
import androidx.room3.ForeignKey
import androidx.room3.Ignore
import androidx.room3.PrimaryKey
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 co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
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.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers
import com.vitorpamplona.quartz.nip14Subject.subject
import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.time.Clock
import kotlin.time.Instant
@Entity(
foreignKeys = [
ForeignKey(
entity = MarmotGroupEvent::class,
parentColumns = ["id"],
childColumns = ["marmotGroupEventId"],
onDelete = ForeignKey.CASCADE,
)
],
)
data class MarmotInnerEvent( // TODO: Rename this to GiftWrapPayload...
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
/**
* Sender Public Key
*/
val publicKey: String,
val chatRoomId: String,
/**
* Payload Event Kind
*/
val kind: Kind = ChatEvent.KIND,
/**
* Tags for the unsigned giftwrap
*
* [
* ["p", "<receiver-1-pubkey>", "<relay-url>"],
* ["p", "<receiver-2-pubkey>", "<relay-url>"],
* ["e", "<kind-14-id>", "<relay-url>"] // if this is a reply
* ["subject", "<conversation-title>"],
* // rest of tags...
* ]
*
* q tags MAY be used when citing events in the .content with NIP-21.
*/
val tags: Array<Array<String>>,
/**
* content MUST be plain text. Fields id and created_at are required.
*/
val content: String,
val quotedEventId: String? = null,
/**
* Associated MarmotGroupEvent
*/
val marmotGroupEventId: String? = null,
/**
* Current time
*/
override val createdAt: Instant = Clock.System.now(),
override val updatedAt: Instant = createdAt,
override val savedAt: Instant = Clock.System.now(),
override val viewedAt: Instant? = null,
override val deletedAt: Instant? = null, // Who this message should be seen by (used to derive conversationId)
): TimestampedEntity,
LocalStoreEntity,
UserViewableEntity,
SoftDeletableEntity {
@Ignore
private val logger = Logger.withTag(TAG)
companion object {
const val TAG = "GiftWrapPayload"
/**
* Compute the NIP-40 expiration timestamp for outbound application messages,
* or `null` when the group does not have disappearing messages configured.
*
* Per MIP-01/MIP-03, when `disappearing_message_secs` is set in the Marmot
* Group Data Extension, clients MUST auto-apply an `expiration` tag on
* kind:445 events at `created_at + disappearing_message_secs`.
*/
fun disappearingExpiration(
group: MlsGroup,
createdAt: Long,
): Long? {
val extensions = group.extensions ?: return null
val marmotData = MarmotGroupData.fromExtensions(extensions) ?: return null
val secs = marmotData.disappearingMessageSecs ?: return null
return createdAt + secs.toLong()
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
other as MarmotInnerEvent
if (id != other.id) return false
if (kind != other.kind) return false
if (publicKey != other.publicKey) return false
if (chatRoomId != other.chatRoomId) return false
if (!tags.contentDeepEquals(other.tags)) return false
if (content != other.content) return false
if (quotedEventId != other.quotedEventId) return false
if (marmotGroupEventId != other.marmotGroupEventId) return false
if (createdAt != other.createdAt) return false
if (updatedAt != other.updatedAt) return false
if (savedAt != other.savedAt) return false
if (viewedAt != other.viewedAt) return false
if (deletedAt != other.deletedAt) return false
if (logger != other.logger) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + kind
result = 31 * result + publicKey.hashCode()
result = 31 * result + chatRoomId.hashCode()
result = 31 * result + tags.contentDeepHashCode()
result = 31 * result + content.hashCode()
result = 31 * result + (quotedEventId?.hashCode() ?: 0)
result = 31 * result + (marmotGroupEventId?.hashCode() ?: 0)
result = 31 * result + createdAt.hashCode()
result = 31 * result + updatedAt.hashCode()
result = 31 * result + savedAt.hashCode()
result = 31 * result + (viewedAt?.hashCode() ?: 0)
result = 31 * result + (deletedAt?.hashCode() ?: 0)
result = 31 * result + logger.hashCode()
return result
}
suspend fun toSignedNostrEvent(group: MlsGroup): GroupEvent {
// TODO: Assert group.id == chatRoomId
val innerEventTemplate = eventTemplate<Event>(
kind = kind,
description = content
)
val rumorEvent = RumorAssembler.assembleRumor(
publicKey,
innerEventTemplate
)
val mlsCiphertext = group.encrypt(rumorEvent.toJson().encodeToByteArray())
val exporterKey = group.exporterSecret(
"marmot",
"group-event".encodeToByteArray(),
32
)
val encryptedContent = GroupEventEncryption.encrypt(mlsCiphertext, exporterKey)
val createdAt = TimeUtils.now()
val expirationTime = disappearingExpiration(group, createdAt)
val template =
GroupEvent.build(
encryptedContentBase64 = encryptedContent,
nostrGroupId = chatRoomId,
createdAt = createdAt,
) {
if (expirationTime != null) expiration(expirationTime)
}
// Step 4: Sign with a fresh ephemeral keypair
val ephemeralSigner = NostrSignerInternal(KeyPair())
val signedEvent: GroupEvent = ephemeralSigner.sign(template)
return GroupEvent(
id = signedEvent.id,
content = signedEvent.content,
createdAt = signedEvent.createdAt,
tags = signedEvent.tags,
sig = signedEvent.sig,
pubKey = signedEvent.pubKey
)
}
}

View File

@@ -37,6 +37,9 @@ data class MarmotKeyPackageBundle(
val ncryptsecEncryptionPrivateKey: String,
val ncryptsecSignaturePrivateKey: String,
val consumed: Boolean = false,
val rotated: Boolean = false, // TODO: Should we have rotatedByID
override val createdAt: Instant = Clock.System.now(),
override val updatedAt: Instant = Clock.System.now(),
override val savedAt: Instant = Clock.System.now(),

View File

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

View File

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

View File

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