Marmot ingestion and all
This commit is contained in:
@@ -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...
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
package at.torch.compose.exceptions
|
||||
|
||||
class MarmotMissingRetainedExporterSecretsException(message: String? = null, cause: Throwable? = null) : Exception(message, cause)
|
||||
@@ -1,4 +0,0 @@
|
||||
package at.torch.compose.exceptions
|
||||
|
||||
class MarmotWelcomeMismatchedGroupId(message: String? = null, cause: Throwable? = null) : Exception(message, cause) {
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package at.torch.compose.exceptions
|
||||
|
||||
class MarmotWelcomeMismatchedGroupIdException(message: String? = null, cause: Throwable? = null) : Exception(message, cause) {
|
||||
}
|
||||
Reference in New Issue
Block a user