From 33ae066bdc59122441759861f2f0e16cb0ec4d42 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 14:23:55 +0000 Subject: [PATCH] feat: add Marmot Protocol event definitions for MLS-over-Nostr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Nostr event kinds for the Marmot Protocol (MIP-00 through MIP-05), which provides end-to-end encrypted group messaging using MLS (RFC 9420) with Nostr as the identity and transport layer. Event kinds added: - kind 30443: KeyPackage (addressable, MIP-00) — MLS KeyPackage publication - kind 10051: KeyPackage Relay List (replaceable, MIP-00) — KeyPackage discovery - kind 444: Welcome (MIP-02) — gift-wrapped group onboarding - kind 445: Group Event (MIP-03) — encrypted group messages and control - kind 446: Notification Request (MIP-05) — push notification trigger - kind 447: Token Request (MIP-05) — push token gossip - kind 448: Token List (MIP-05) — push token sync response - kind 449: Token Removal (MIP-05) — push token cleanup Also includes: - MarmotGroupData model (MIP-01) — 0xF2EE extension data structure - MlsCiphersuite enum — RFC 9420 ciphersuite registry - Full tag definitions following existing quartz patterns https://claude.ai/code/session_017jmJgCAXnaiVh1HsbC8CrW --- .../mip00KeyPackages/KeyPackageEvent.kt | 113 ++++++++ .../KeyPackageRelayListEvent.kt | 91 ++++++ .../mip00KeyPackages/TagArrayBuilderExt.kt | 49 ++++ .../marmot/mip00KeyPackages/TagArrayExt.kt | 47 ++++ .../marmot/mip00KeyPackages/tags/ClientTag.kt | 42 +++ .../mip00KeyPackages/tags/EncodingTag.kt | 43 +++ .../mip00KeyPackages/tags/KeyPackageRefTag.kt | 48 ++++ .../tags/MlsCiphersuiteTag.kt | 43 +++ .../mip00KeyPackages/tags/MlsExtensionsTag.kt | 51 ++++ .../mip00KeyPackages/tags/MlsProposalsTag.kt | 51 ++++ .../tags/MlsProtocolVersionTag.kt | 43 +++ .../marmot/mip00KeyPackages/tags/RelaysTag.kt | 52 ++++ .../marmot/mip01Groups/MarmotGroupData.kt | 95 +++++++ .../marmot/mip01Groups/MlsCiphersuite.kt | 46 +++ .../marmot/mip02Welcome/TagArrayBuilderExt.kt | 34 +++ .../quartz/marmot/mip02Welcome/TagArrayExt.kt | 32 +++ .../marmot/mip02Welcome/WelcomeEvent.kt | 89 ++++++ .../mip02Welcome/tags/KeyPackageEventTag.kt | 43 +++ .../mip02Welcome/tags/WelcomeRelaysTag.kt | 51 ++++ .../marmot/mip03GroupMessages/GroupEvent.kt | 106 +++++++ .../mip03GroupMessages/TagArrayBuilderExt.kt | 27 ++ .../marmot/mip03GroupMessages/TagArrayExt.kt | 26 ++ .../mip03GroupMessages/tags/GroupIdTag.kt | 46 +++ .../NotificationRequestEvent.kt | 83 ++++++ .../TagArrayBuilderExt.kt | 30 ++ .../mip05PushNotifications/TagArrayExt.kt | 32 +++ .../mip05PushNotifications/TokenListEvent.kt | 74 +++++ .../TokenRemovalEvent.kt | 60 ++++ .../TokenRequestEvent.kt | 67 +++++ .../mip05PushNotifications/tags/TokenTag.kt | 83 ++++++ .../mip05PushNotifications/tags/VersionTag.kt | 43 +++ .../quartz/utils/EventFactory.kt | 265 ++++++++++++++++++ 32 files changed, 2005 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRelayListEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/TagArrayExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/tags/ClientTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/tags/EncodingTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/tags/KeyPackageRefTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/tags/MlsCiphersuiteTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/tags/MlsExtensionsTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/tags/MlsProposalsTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/tags/MlsProtocolVersionTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/tags/RelaysTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MlsCiphersuite.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/TagArrayExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/WelcomeEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/tags/KeyPackageEventTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/tags/WelcomeRelaysTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip03GroupMessages/GroupEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip03GroupMessages/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip03GroupMessages/TagArrayExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip03GroupMessages/tags/GroupIdTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/NotificationRequestEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TagArrayExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenListEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenRemovalEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenRequestEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/tags/TokenTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/tags/VersionTag.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageEvent.kt new file mode 100644 index 000000000..c8d01ddc6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageEvent.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip00KeyPackages + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.EncodingTag +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Marmot KeyPackage Event (MIP-00) — kind 30443. + * + * Addressable event that publishes an MLS KeyPackage for asynchronous group invitations. + * Each KeyPackage is identified by its (kind, pubkey, d-tag) tuple, enabling native + * rotation without NIP-09 deletion events. + * + * Content: base64-encoded TLS-serialized KeyPackageBundle from MLS library. + * + * Required tags: d, mls_protocol_version, mls_ciphersuite, mls_extensions, + * mls_proposals, encoding, i, relays + * Optional tags: client + */ +@Immutable +class KeyPackageEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + /** Base64-encoded TLS-serialized KeyPackageBundle */ + fun keyPackageBase64() = content + + /** MLS protocol version (e.g., "1.0") */ + fun mlsProtocolVersion() = tags.mlsProtocolVersion() + + /** MLS ciphersuite ID (e.g., "0x0001") */ + fun mlsCiphersuite() = tags.mlsCiphersuite() + + /** Supported non-default MLS extension IDs */ + fun mlsExtensions() = tags.mlsExtensions() + + /** Supported non-default MLS proposal type IDs */ + fun mlsProposals() = tags.mlsProposals() + + /** Content encoding format (must be "base64") */ + fun encoding() = tags.encoding() + + /** Hex-encoded KeyPackageRef for efficient relay queries */ + fun keyPackageRef() = tags.keyPackageRef() + + /** Relays where this KeyPackage is published */ + fun relays() = tags.keyPackageRelays() + + /** Optional client name */ + fun clientName() = tags.clientName() + + /** Whether content encoding is valid (must be base64) */ + fun hasValidEncoding() = encoding() == EncodingTag.BASE64 + + companion object { + const val KIND = 30443 + const val ALT_DESCRIPTION = "MLS KeyPackage" + + fun build( + keyPackageBase64: String, + dTagSlot: String, + keyPackageRef: HexKey, + relays: List, + ciphersuite: String = "0x0001", + clientName: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, keyPackageBase64, createdAt) { + alt(ALT_DESCRIPTION) + dTag(dTagSlot) + mlsProtocolVersion() + mlsCiphersuite(ciphersuite) + mlsExtensions(listOf("0xf2ee", "0x000a")) + mlsProposals(listOf("0x000a")) + encoding() + keyPackageRef(keyPackageRef) + keyPackageRelays(relays) + clientName?.let { client(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRelayListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRelayListEvent.kt new file mode 100644 index 000000000..d7c2d3b48 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRelayListEvent.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip00KeyPackages + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Marmot KeyPackage Relay List Event (MIP-00) — kind 10051. + * + * Replaceable event that advertises which relays hold this user's MLS KeyPackages. + * Other clients query this to discover where to fetch KeyPackages for group invitations. + * + * Uses the same "relay" tag format as NIP-17's ChatMessageRelayListEvent (kind 10050) + * but serves a different purpose: KeyPackage discovery vs DM inbox routing. + */ +@Immutable +class KeyPackageRelayListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun relays(): List = tags.mapNotNull(RelayTag::parse) + + companion object { + const val KIND = 10051 + const val ALT_DESCRIPTION = "MLS KeyPackage relay list" + + fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, FIXED_D_TAG) + + fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG) + + private fun createTagArray(relays: List): Array> = + relays + .map { RelayTag.assemble(it) } + .plusElement(AltTag.assemble(ALT_DESCRIPTION)) + .toTypedArray() + + suspend fun create( + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): KeyPackageRelayListEvent = signer.sign(createdAt, KIND, createTagArray(relays), "") + } +} + +/** + * Relay tag parser for KeyPackage relay list events. + * Uses the standard "relay" tag name. + */ +private object RelayTag { + const val TAG_NAME = "relay" + + fun parse(tag: Array): NormalizedRelayUrl? { + if (tag.size < 2 || tag[0] != TAG_NAME || tag[1].isEmpty()) return null + val relay = + com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + .normalizeOrNull(tag[1]) + return relay?.takeUnless { it.isLocalHost() } + } + + fun assemble(relay: NormalizedRelayUrl) = arrayOf(TAG_NAME, relay.url) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/TagArrayBuilderExt.kt new file mode 100644 index 000000000..6f7de3a7e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/TagArrayBuilderExt.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip00KeyPackages + +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.ClientTag +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.EncodingTag +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.KeyPackageRefTag +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.MlsCiphersuiteTag +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.MlsExtensionsTag +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.MlsProposalsTag +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.MlsProtocolVersionTag +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.RelaysTag +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +fun TagArrayBuilder.mlsProtocolVersion(version: String = MlsProtocolVersionTag.CURRENT_VERSION) = addUnique(MlsProtocolVersionTag.assemble(version)) + +fun TagArrayBuilder.mlsCiphersuite(ciphersuite: String = MlsCiphersuiteTag.DEFAULT_CIPHERSUITE) = addUnique(MlsCiphersuiteTag.assemble(ciphersuite)) + +fun TagArrayBuilder.mlsExtensions(extensionIds: List) = addUnique(MlsExtensionsTag.assemble(extensionIds)) + +fun TagArrayBuilder.mlsProposals(proposalIds: List) = addUnique(MlsProposalsTag.assemble(proposalIds)) + +fun TagArrayBuilder.encoding() = addUnique(EncodingTag.assemble()) + +fun TagArrayBuilder.keyPackageRef(ref: HexKey) = addUnique(KeyPackageRefTag.assemble(ref)) + +fun TagArrayBuilder.keyPackageRelays(relays: List) = addUnique(RelaysTag.assemble(relays)) + +fun TagArrayBuilder.client(name: String) = addUnique(ClientTag.assemble(name)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/TagArrayExt.kt new file mode 100644 index 000000000..a754120e0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/TagArrayExt.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip00KeyPackages + +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.ClientTag +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.EncodingTag +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.KeyPackageRefTag +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.MlsCiphersuiteTag +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.MlsExtensionsTag +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.MlsProposalsTag +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.MlsProtocolVersionTag +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.RelaysTag +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +fun TagArray.mlsProtocolVersion() = firstNotNullOfOrNull(MlsProtocolVersionTag::parse) + +fun TagArray.mlsCiphersuite() = firstNotNullOfOrNull(MlsCiphersuiteTag::parse) + +fun TagArray.mlsExtensions() = firstNotNullOfOrNull(MlsExtensionsTag::parse) + +fun TagArray.mlsProposals() = firstNotNullOfOrNull(MlsProposalsTag::parse) + +fun TagArray.encoding() = firstNotNullOfOrNull(EncodingTag::parse) + +fun TagArray.keyPackageRef() = firstNotNullOfOrNull(KeyPackageRefTag::parse) + +fun TagArray.keyPackageRelays() = firstNotNullOfOrNull(RelaysTag::parse) + +fun TagArray.clientName() = firstNotNullOfOrNull(ClientTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/tags/ClientTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/tags/ClientTag.kt new file mode 100644 index 000000000..e79211a41 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/tags/ClientTag.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * Optional client name tag for KeyPackage events. + * Helps with UX when users can't access signing keys across devices. + */ +class ClientTag { + companion object { + const val TAG_NAME = "client" + + fun parse(tag: Array): String? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(clientName: String) = arrayOf(TAG_NAME, clientName) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/tags/EncodingTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/tags/EncodingTag.kt new file mode 100644 index 000000000..fd2b3eaff --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/tags/EncodingTag.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * Encoding format tag for MLS content fields. + * Only "base64" is supported; hex encoding is deprecated and MUST be rejected. + */ +class EncodingTag { + companion object { + const val TAG_NAME = "encoding" + const val BASE64 = "base64" + + fun parse(tag: Array): String? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1] == BASE64) { return null } + return tag[1] + } + + fun assemble() = arrayOf(TAG_NAME, BASE64) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/tags/KeyPackageRefTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/tags/KeyPackageRefTag.kt new file mode 100644 index 000000000..c74cea97f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/tags/KeyPackageRefTag.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * KeyPackageRef tag ("i") for efficient relay queries of KeyPackages. + * + * Contains the hex-encoded KeyPackageRef computed per RFC 9420 Section 5.2: + * MakeKeyPackageRef(value) = RefHash("MLS 1.0 KeyPackage Reference", value) + * + * The hash function MUST be the one defined by the KeyPackage's ciphersuite + * (e.g., SHA-256 for ciphersuite 0x0001). + */ +class KeyPackageRefTag { + companion object { + const val TAG_NAME = "i" + + fun parse(tag: Array): HexKey? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(keyPackageRef: HexKey) = arrayOf(TAG_NAME, keyPackageRef) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/tags/MlsCiphersuiteTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/tags/MlsCiphersuiteTag.kt new file mode 100644 index 000000000..0728d15bc --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/tags/MlsCiphersuiteTag.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * MLS ciphersuite identifier tag per RFC 9420 Section 17.1. + * Default Marmot ciphersuite: "0x0001" (MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519). + */ +class MlsCiphersuiteTag { + companion object { + const val TAG_NAME = "mls_ciphersuite" + const val DEFAULT_CIPHERSUITE = "0x0001" + + fun parse(tag: Array): String? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(ciphersuite: String = DEFAULT_CIPHERSUITE) = arrayOf(TAG_NAME, ciphersuite) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/tags/MlsExtensionsTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/tags/MlsExtensionsTag.kt new file mode 100644 index 000000000..dbc36a6ea --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/tags/MlsExtensionsTag.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * Supported non-default MLS extension type IDs per RFC 9420. + * Marmot requires: 0xf2ee (marmot_group_data) and 0x000a (last_resort). + * + * Example tag: ["mls_extensions", "0xf2ee", "0x000a"] + */ +class MlsExtensionsTag { + companion object { + const val TAG_NAME = "mls_extensions" + + /** Marmot Group Data extension (MIP-01) */ + const val MARMOT_GROUP_DATA = "0xf2ee" + + /** Last resort KeyPackage extension */ + const val LAST_RESORT = "0x000a" + + fun parse(tag: Array): List? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + return tag.drop(1).filter { it.isNotEmpty() } + } + + fun assemble(extensionIds: List) = arrayOf(TAG_NAME, *extensionIds.toTypedArray()) + + fun assembleDefault() = assemble(listOf(MARMOT_GROUP_DATA, LAST_RESORT)) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/tags/MlsProposalsTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/tags/MlsProposalsTag.kt new file mode 100644 index 000000000..e5927f0cb --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/tags/MlsProposalsTag.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * Supported non-default MLS proposal type IDs. + * Marmot requires: 0x000a (self_remove) per MLS Extensions draft. + * + * Note: 0x000a is used for both self_remove (proposal type) and last_resort + * (extension type) but they belong to different IANA registries. + * + * Example tag: ["mls_proposals", "0x000a"] + */ +class MlsProposalsTag { + companion object { + const val TAG_NAME = "mls_proposals" + + /** SelfRemove proposal type (MIP-03) */ + const val SELF_REMOVE = "0x000a" + + fun parse(tag: Array): List? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + return tag.drop(1).filter { it.isNotEmpty() } + } + + fun assemble(proposalIds: List) = arrayOf(TAG_NAME, *proposalIds.toTypedArray()) + + fun assembleDefault() = assemble(listOf(SELF_REMOVE)) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/tags/MlsProtocolVersionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/tags/MlsProtocolVersionTag.kt new file mode 100644 index 000000000..949ea40c8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/tags/MlsProtocolVersionTag.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * MLS protocol version tag for KeyPackage events. + * Currently "1.0" per RFC 9420. + */ +class MlsProtocolVersionTag { + companion object { + const val TAG_NAME = "mls_protocol_version" + const val CURRENT_VERSION = "1.0" + + fun parse(tag: Array): String? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(version: String = CURRENT_VERSION) = arrayOf(TAG_NAME, version) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/tags/RelaysTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/tags/RelaysTag.kt new file mode 100644 index 000000000..d512b5491 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/tags/RelaysTag.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost +import com.vitorpamplona.quartz.utils.ensure + +/** + * Multi-value relay tag for KeyPackage events. + * Lists relays where this KeyPackage is published. + * MUST contain at least one valid WebSocket URL. + * + * Example tag: ["relays", "wss://relay1.com", "wss://relay2.com"] + */ +class RelaysTag { + companion object { + const val TAG_NAME = "relays" + + fun parse(tag: Array): List? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + val relays = + tag.drop(1).mapNotNull { url -> + RelayUrlNormalizer.normalizeOrNull(url)?.takeUnless { it.isLocalHost() } + } + ensure(relays.isNotEmpty()) { return null } + return relays + } + + fun assemble(relays: List) = arrayOf(TAG_NAME, *relays.map { it.url }.toTypedArray()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt new file mode 100644 index 000000000..5e7103584 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip01Groups + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Marmot Group Data Extension (MIP-01) — extension ID 0xF2EE. + * + * This data model represents the NostrGroupData structure carried inside + * the MLS GroupContext.extensions. It links MLS group encryption with + * Nostr's decentralized identity system. + * + * The actual TLS serialization/deserialization is handled by the MLS engine. + * This class provides a Kotlin-friendly representation for the application layer. + * + * Wire format (TLS presentation language): + * ``` + * struct { + * uint16 version; // Current: 2 + * opaque nostr_group_id[32]; // Nostr routing ID (distinct from MLS group ID) + * opaque name<0..2^16-1>; + * opaque description<0..2^16-1>; + * opaque admin_pubkeys<0..2^16-1>; // Concatenated raw 32-byte x-only pubkeys + * RelayUrl relays<0..2^16-1>; + * opaque image_hash<0..32>; + * opaque image_key<0..32>; // HKDF seed for encryption key derivation + * opaque image_nonce<0..12>; + * opaque image_upload_key<0..32>; // HKDF seed for upload keypair derivation + * } NostrGroupData; + * ``` + */ +@Immutable +data class MarmotGroupData( + /** Extension format version. Current: 2 */ + val version: Int = CURRENT_VERSION, + /** + * 32-byte Nostr routing ID (hex-encoded). + * Distinct from the private MLS group ID. Used in "h" tags of kind:445 events. + * MUST be cryptographically random when initially generated. + */ + val nostrGroupId: HexKey, + /** UTF-8 encoded group name. Empty string for unnamed groups. */ + val name: String = "", + /** UTF-8 encoded group description. Empty string if not set. */ + val description: String = "", + /** + * Admin public keys (hex-encoded, 32-byte x-only secp256k1 pubkeys). + * At least one admin key MUST be present for most group operations. + * MUST NOT contain duplicates. + */ + val adminPubkeys: List = emptyList(), + /** Relay URLs for group message distribution. SHOULD contain at least one. */ + val relays: List = emptyList(), + /** SHA-256 hash of the encrypted group image (hex). Empty if no image. */ + val imageHash: HexKey? = null, + /** HKDF seed for deriving the image encryption key. Empty if no image. */ + val imageKey: ByteArray? = null, + /** ChaCha20-Poly1305 nonce for image encryption. Empty if no image. */ + val imageNonce: ByteArray? = null, + /** HKDF seed for deriving the Blossom upload keypair. Empty if no image. */ + val imageUploadKey: ByteArray? = null, +) { + /** Whether the given pubkey is an admin of this group */ + fun isAdmin(pubKey: HexKey): Boolean = adminPubkeys.contains(pubKey) + + /** Whether this group has an encrypted image set */ + fun hasImage(): Boolean = imageHash != null && imageKey != null && imageNonce != null + + companion object { + const val CURRENT_VERSION = 2 + + /** MLS extension type identifier for marmot_group_data */ + const val EXTENSION_ID: UShort = 0xF2EEu + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MlsCiphersuite.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MlsCiphersuite.kt new file mode 100644 index 000000000..bd800032c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MlsCiphersuite.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip01Groups + +/** + * MLS ciphersuites defined in RFC 9420 Section 17.1. + * Marmot default: MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 (0x0001). + */ +enum class MlsCiphersuite( + val code: String, + val hashAlgorithm: String, + val hashOutputBytes: Int, +) { + MLS_128_DHKEMX25519_AES128GCM_SHA256_ED25519("0x0001", "SHA-256", 32), + MLS_128_DHKEMP256_AES128GCM_SHA256_P256("0x0002", "SHA-256", 32), + MLS_128_DHKEMX25519_CHACHA20POLY1305_SHA256_ED25519("0x0003", "SHA-256", 32), + MLS_256_DHKEMX448_AES256GCM_SHA512_ED448("0x0004", "SHA-512", 64), + MLS_256_DHKEMP521_AES256GCM_SHA512_P521("0x0005", "SHA-512", 64), + MLS_256_DHKEMX448_CHACHA20POLY1305_SHA512_ED448("0x0006", "SHA-512", 64), + MLS_256_DHKEMP384_AES256GCM_SHA384_P384("0x0007", "SHA-384", 48), + ; + + companion object { + val DEFAULT = MLS_128_DHKEMX25519_AES128GCM_SHA256_ED25519 + + fun fromCode(code: String): MlsCiphersuite? = entries.find { it.code == code } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/TagArrayBuilderExt.kt new file mode 100644 index 000000000..314a8b432 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/TagArrayBuilderExt.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip02Welcome + +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.EncodingTag +import com.vitorpamplona.quartz.marmot.mip02Welcome.tags.KeyPackageEventTag +import com.vitorpamplona.quartz.marmot.mip02Welcome.tags.WelcomeRelaysTag +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +fun TagArrayBuilder.keyPackageEventId(eventId: HexKey) = addUnique(KeyPackageEventTag.assemble(eventId)) + +fun TagArrayBuilder.welcomeRelays(relays: List) = addUnique(WelcomeRelaysTag.assemble(relays)) + +fun TagArrayBuilder.encoding() = addUnique(EncodingTag.assemble()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/TagArrayExt.kt new file mode 100644 index 000000000..4a7b7a422 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/TagArrayExt.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip02Welcome + +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.EncodingTag +import com.vitorpamplona.quartz.marmot.mip02Welcome.tags.KeyPackageEventTag +import com.vitorpamplona.quartz.marmot.mip02Welcome.tags.WelcomeRelaysTag +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +fun TagArray.keyPackageEventId() = firstNotNullOfOrNull(KeyPackageEventTag::parse) + +fun TagArray.welcomeRelays() = firstNotNullOfOrNull(WelcomeRelaysTag::parse) + +fun TagArray.welcomeEncoding() = firstNotNullOfOrNull(EncodingTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/WelcomeEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/WelcomeEvent.kt new file mode 100644 index 000000000..db40824a6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/WelcomeEvent.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip02Welcome + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Marmot Welcome Event (MIP-02) — kind 444. + * + * An unsigned rumor event (no sig) that carries an MLS Welcome message for + * onboarding new members to a group. Delivered via NIP-59 gift wrap: + * + * GiftWrap(kind:1059) → Seal(kind:13) → WelcomeEvent(kind:444, unsigned) + * + * Content: base64-encoded MLSMessage with wire_format = mls_welcome. + * + * CRITICAL timing requirement: The Commit that adds this member MUST be + * confirmed by relays BEFORE the Welcome is sent, to prevent state forks. + * + * After processing a Welcome, the new member MUST: + * 1. Rotate their KeyPackage (publish new kind:30443 under same d-tag) + * 2. Securely delete the init_key private material + * 3. Perform a self-update within 24 hours for forward secrecy + */ +@Immutable +class WelcomeEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + /** Base64-encoded MLSMessage (wire_format = mls_welcome) */ + fun welcomeBase64() = content + + /** Event ID of the KeyPackage that was consumed for this invitation */ + fun keyPackageEventId() = tags.keyPackageEventId() + + /** Relays where the new member should look for Group Events */ + fun relays() = tags.welcomeRelays() + + /** Content encoding (must be "base64") */ + fun encoding() = tags.welcomeEncoding() + + override fun isContentEncoded() = true + + companion object { + const val KIND = 444 + const val ALT_DESCRIPTION = "MLS Welcome" + + fun build( + welcomeBase64: String, + keyPackageEventId: HexKey, + relays: List, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, welcomeBase64, createdAt) { + keyPackageEventId(keyPackageEventId) + welcomeRelays(relays) + encoding() + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/tags/KeyPackageEventTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/tags/KeyPackageEventTag.kt new file mode 100644 index 000000000..ef16518ad --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/tags/KeyPackageEventTag.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip02Welcome.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * Event reference tag ("e") pointing to the KeyPackage event (kind:30443) + * that was consumed to add this member to the group. + */ +class KeyPackageEventTag { + companion object { + const val TAG_NAME = "e" + + fun parse(tag: Array): HexKey? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + return tag[1] + } + + fun assemble(keyPackageEventId: HexKey) = arrayOf(TAG_NAME, keyPackageEventId) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/tags/WelcomeRelaysTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/tags/WelcomeRelaysTag.kt new file mode 100644 index 000000000..d4fc18c89 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/tags/WelcomeRelaysTag.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip02Welcome.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost +import com.vitorpamplona.quartz.utils.ensure + +/** + * Relay list tag for Welcome events. + * Tells the new member where to find Group Events for this group. + * + * Example tag: ["relays", "wss://relay1.com", "wss://relay2.com"] + */ +class WelcomeRelaysTag { + companion object { + const val TAG_NAME = "relays" + + fun parse(tag: Array): List? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + val relays = + tag.drop(1).mapNotNull { url -> + RelayUrlNormalizer.normalizeOrNull(url)?.takeUnless { it.isLocalHost() } + } + ensure(relays.isNotEmpty()) { return null } + return relays + } + + fun assemble(relays: List) = arrayOf(TAG_NAME, *relays.map { it.url }.toTypedArray()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip03GroupMessages/GroupEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip03GroupMessages/GroupEvent.kt new file mode 100644 index 000000000..396aacdcd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip03GroupMessages/GroupEvent.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip03GroupMessages + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Marmot Group Event (MIP-03) — kind 445. + * + * Carries both control messages (Proposals, Commits) and application messages + * (chat, reactions) for encrypted group communication. + * + * Content: base64(nonce || ciphertext) where ciphertext is ChaCha20-Poly1305 + * encrypted MLSMessage using key derived from MLS-Exporter("marmot", "group-event", 32). + * + * Privacy properties: + * - pubkey is an ephemeral key (different for each event, MUST NOT be reused) + * - "h" tag contains nostr_group_id for relay routing (not the private MLS group ID) + * - Content is double-encrypted: MLS framing + ChaCha20-Poly1305 outer layer + * + * Inner application messages are unsigned Nostr events: + * - kind:9 for chat messages + * - kind:7 for reactions + * - Other appropriate Nostr event kinds + * + * Commit ordering for race conditions: + * 1. Lowest created_at wins + * 2. If tied, lexicographically smallest event id wins + * 3. All other competing Commits are discarded + */ +@Immutable +class GroupEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + /** + * Base64-encoded encrypted content: nonce(12 bytes) || ciphertext. + * Decrypt with ChaCha20-Poly1305 using the MLS exporter-derived key. + */ + fun encryptedContent() = content + + /** Nostr group ID from the Marmot Group Data Extension (for relay routing) */ + fun groupId() = tags.marmotGroupId() + + override fun isContentEncoded() = true + + companion object { + const val KIND = 445 + const val ALT_DESCRIPTION = "Encrypted group message" + + /** MLS Exporter label for deriving the group event encryption key */ + const val EXPORTER_LABEL = "marmot" + + /** MLS Exporter context for deriving the group event encryption key */ + const val EXPORTER_CONTEXT = "group-event" + + /** Length of the MLS exporter-derived key in bytes */ + const val EXPORTER_KEY_LENGTH = 32 + + /** ChaCha20-Poly1305 nonce length in bytes */ + const val NONCE_LENGTH = 12 + + /** ChaCha20-Poly1305 auth tag length in bytes */ + const val AUTH_TAG_LENGTH = 16 + + /** Minimum base64-decoded content length: nonce + auth tag */ + const val MIN_CONTENT_LENGTH = NONCE_LENGTH + AUTH_TAG_LENGTH + + fun build( + encryptedContentBase64: String, + nostrGroupId: HexKey, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, encryptedContentBase64, createdAt) { + marmotGroupId(nostrGroupId) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip03GroupMessages/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip03GroupMessages/TagArrayBuilderExt.kt new file mode 100644 index 000000000..b1aa3f0eb --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip03GroupMessages/TagArrayBuilderExt.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip03GroupMessages + +import com.vitorpamplona.quartz.marmot.mip03GroupMessages.tags.GroupIdTag +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.marmotGroupId(nostrGroupId: HexKey) = addUnique(GroupIdTag.assemble(nostrGroupId)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip03GroupMessages/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip03GroupMessages/TagArrayExt.kt new file mode 100644 index 000000000..09e720907 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip03GroupMessages/TagArrayExt.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip03GroupMessages + +import com.vitorpamplona.quartz.marmot.mip03GroupMessages.tags.GroupIdTag +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +fun TagArray.marmotGroupId() = firstNotNullOfOrNull(GroupIdTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip03GroupMessages/tags/GroupIdTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip03GroupMessages/tags/GroupIdTag.kt new file mode 100644 index 000000000..e38672816 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip03GroupMessages/tags/GroupIdTag.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip03GroupMessages.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * Group ID tag ("h") for Marmot Group Events. + * + * Contains the nostr_group_id from the Marmot Group Data Extension (MIP-01). + * Used for relay routing and group message filtering. + * This is NOT the private MLS group ID — it's a separate routing identifier. + */ +class GroupIdTag { + companion object { + const val TAG_NAME = "h" + + fun parse(tag: Array): HexKey? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(nostrGroupId: HexKey) = arrayOf(TAG_NAME, nostrGroupId) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/NotificationRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/NotificationRequestEvent.kt new file mode 100644 index 000000000..e942ee4ef --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/NotificationRequestEvent.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip05PushNotifications + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.EncodingTag +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.tags.VersionTag +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Marmot Notification Request Event (MIP-05) — kind 446. + * + * An unsigned rumor event delivered via NIP-59 gift wrap to the notification server. + * Contains concatenated EncryptedTokens (each 280 bytes), base64-encoded. + * + * Flow: Rumor(kind:446) → Seal(kind:13) → GiftWrap(kind:1059) → notification server + * + * The pubkey MUST be a fresh ephemeral key (not the sender's identity) + * to prevent the notification server from linking events to users. + * + * Content includes real group tokens plus decoy tokens from other groups + * (shuffled) to obscure group size and prevent social graph inference. + */ +@Immutable +class NotificationRequestEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + /** + * Base64-encoded concatenation of EncryptedTokens. + * Each token is exactly 280 bytes when decoded. + * Total decoded length MUST be a multiple of 280. + */ + fun tokensBase64() = content + + /** Notification protocol version (must be "mip05-v1") */ + fun version() = tags.notificationVersion() + + /** Content encoding (must be "base64") */ + fun encoding() = tags.notificationEncoding() + + override fun isContentEncoded() = true + + companion object { + const val KIND = 446 + + fun build( + tokensBase64: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, tokensBase64, createdAt) { + addUnique(VersionTag.assemble()) + addUnique(EncodingTag.assemble()) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TagArrayBuilderExt.kt new file mode 100644 index 000000000..d53e616f2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TagArrayBuilderExt.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip05PushNotifications + +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.tags.TokenTag +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.tags.TokenTagData +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.tokens(tokens: List) = addAll(TokenTag.assemble(tokens)) + +fun TagArrayBuilder.token(data: TokenTagData) = add(TokenTag.assemble(data)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TagArrayExt.kt new file mode 100644 index 000000000..5895189d5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TagArrayExt.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip05PushNotifications + +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.EncodingTag +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.tags.TokenTag +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.tags.VersionTag +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +fun TagArray.notificationVersion() = firstNotNullOfOrNull(VersionTag::parse) + +fun TagArray.notificationEncoding() = firstNotNullOfOrNull(EncodingTag::parse) + +fun TagArray.tokens() = mapNotNull(TokenTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenListEvent.kt new file mode 100644 index 000000000..cd518b0e0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenListEvent.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip05PushNotifications + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.tags.TokenTagData +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Marmot Token List Response Event (MIP-05) — kind 448. + * + * Unsigned application message sent inside a GroupEvent (kind:445) in response + * to a TokenRequestEvent (kind:447). Contains the responder's complete view + * of all active encrypted device tokens in the group. + * + * Token tags include a leaf_index field (4th value) to identify which + * MLS leaf owns each token. An "e" tag references the kind:447 event + * this is responding to. + * + * Members SHOULD add random delay (0-2s) before responding. + * MUST remain unsigned (no sig field) per MIP-03 security requirements. + */ +@Immutable +class TokenListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + /** All known encrypted tokens with their leaf indices */ + fun tokens() = tags.tokens() + + /** Event ID of the kind:447 request this responds to */ + fun requestEventId() = tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1) + + companion object { + const val KIND = 448 + + fun build( + allTokens: List, + requestEventId: HexKey, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + tokens(allTokens) + add(arrayOf("e", requestEventId)) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenRemovalEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenRemovalEvent.kt new file mode 100644 index 000000000..8cad85a6e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenRemovalEvent.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip05PushNotifications + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Marmot Token Removal Event (MIP-05) — kind 449. + * + * Unsigned application message sent inside a GroupEvent (kind:445) when a device + * leaves a group or wants to disable push notifications. + * + * Has no tags — the MLS leaf index is implicit from the MLS sender identity. + * Receiving clients MUST remove the token for the identified leaf. + * + * MUST remain unsigned (no sig field) per MIP-03 security requirements. + */ +@Immutable +class TokenRemovalEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 449 + + fun build( + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenRequestEvent.kt new file mode 100644 index 000000000..f0ca60957 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenRequestEvent.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip05PushNotifications + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.tags.TokenTagData +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Marmot Token Request Event (MIP-05) — kind 447. + * + * Unsigned application message sent inside a GroupEvent (kind:445) when a device + * joins a group, needs to refresh its token view, or has a token change. + * + * Includes the sender's own encrypted token in "token" tags to bootstrap + * the device into the group's notification system immediately. + * + * The MLS leaf index is implicit from the MLS sender identity. + * MUST remain unsigned (no sig field) per MIP-03 security requirements. + */ +@Immutable +class TokenRequestEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + /** Encrypted tokens included with this request (sender's own tokens) */ + fun tokens() = tags.tokens() + + companion object { + const val KIND = 447 + + fun build( + ownTokens: List, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + tokens(ownTokens) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/tags/TokenTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/tags/TokenTag.kt new file mode 100644 index 000000000..6eca9b6f8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/tags/TokenTag.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip05PushNotifications.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * Encrypted token tag for push notification token distribution (kinds 447, 448). + * + * Each EncryptedToken is exactly 280 bytes: + * ephemeral_pubkey(32) || nonce(12) || ciphertext(236) + * + * The token payload inside is padded to 220 bytes: + * platform(1) || token_length(2 BE) || device_token(N) || random_padding(220-3-N) + * + * Platform values: 0x01 = APNs, 0x02 = FCM. + */ +@Immutable +data class TokenTagData( + /** Base64-encoded EncryptedToken (280 bytes when decoded) */ + val encryptedToken: String, + /** Hex-encoded notification server public key */ + val serverPubKey: HexKey, + /** Relay hint URL for finding the server's kind:10050 event */ + val relayHint: String, + /** MLS leaf index of the token owner (only present in kind:448 responses) */ + val leafIndex: Int? = null, +) + +class TokenTag { + companion object { + const val TAG_NAME = "token" + + /** Expected decoded size of an EncryptedToken */ + const val ENCRYPTED_TOKEN_SIZE = 280 + + fun parse(tag: Array): TokenTagData? { + ensure(tag.has(3) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + ensure(tag[2].length == 64) { return null } + ensure(tag[3].isNotEmpty()) { return null } + + val leafIndex = tag.getOrNull(4)?.toIntOrNull() + + return TokenTagData( + encryptedToken = tag[1], + serverPubKey = tag[2], + relayHint = tag[3], + leafIndex = leafIndex, + ) + } + + fun assemble(data: TokenTagData): Array = + if (data.leafIndex != null) { + arrayOf(TAG_NAME, data.encryptedToken, data.serverPubKey, data.relayHint, data.leafIndex.toString()) + } else { + arrayOf(TAG_NAME, data.encryptedToken, data.serverPubKey, data.relayHint) + } + + fun assemble(tokens: List) = tokens.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/tags/VersionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/tags/VersionTag.kt new file mode 100644 index 000000000..87542573d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/tags/VersionTag.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip05PushNotifications.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * Version tag for Marmot push notification events (kind 446). + * Current version: "mip05-v1". + */ +class VersionTag { + companion object { + const val TAG_NAME = "v" + const val CURRENT_VERSION = "mip05-v1" + + fun parse(tag: Array): String? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1] == CURRENT_VERSION) { return null } + return tag[1] + } + + fun assemble() = arrayOf(TAG_NAME, CURRENT_VERSION) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 48ad5ff4a..31ebc2e9c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -42,6 +42,14 @@ import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent +import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent +import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.NotificationRequestEvent +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.TokenListEvent +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.TokenRemovalEvent +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.TokenRequestEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher @@ -291,245 +299,502 @@ class EventFactory { ): T = when (kind) { AcceptedBadgeSetEvent.KIND -> AcceptedBadgeSetEvent(id, pubKey, createdAt, tags, content, sig) + AdvertisedRelayListEvent.KIND -> AdvertisedRelayListEvent(id, pubKey, createdAt, tags, content, sig) + AppCurationSetEvent.KIND -> AppCurationSetEvent(id, pubKey, createdAt, tags, content, sig) + AppDefinitionEvent.KIND -> AppDefinitionEvent(id, pubKey, createdAt, tags, content, sig) + AppRecommendationEvent.KIND -> AppRecommendationEvent(id, pubKey, createdAt, tags, content, sig) + AppSpecificDataEvent.KIND -> AppSpecificDataEvent(id, pubKey, createdAt, tags, content, sig) + AttestationEvent.KIND -> AttestationEvent(id, pubKey, createdAt, tags, content, sig) + AttestationRequestEvent.KIND -> AttestationRequestEvent(id, pubKey, createdAt, tags, content, sig) + AttestorRecommendationEvent.KIND -> AttestorRecommendationEvent(id, pubKey, createdAt, tags, content, sig) + AttestorProficiencyEvent.KIND -> AttestorProficiencyEvent(id, pubKey, createdAt, tags, content, sig) + ArticleCurationSetEvent.KIND -> ArticleCurationSetEvent(id, pubKey, createdAt, tags, content, sig) + AudioHeaderEvent.KIND -> AudioHeaderEvent(id, pubKey, createdAt, tags, content, sig) + AuctionEvent.KIND -> AuctionEvent(id, pubKey, createdAt, tags, content, sig) + AudioTrackEvent.KIND -> AudioTrackEvent(id, pubKey, createdAt, tags, content, sig) + BadgeAwardEvent.KIND -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig) + BadgeDefinitionEvent.KIND -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig) + BidEvent.KIND -> BidEvent(id, pubKey, createdAt, tags, content, sig) + BidConfirmationEvent.KIND -> BidConfirmationEvent(id, pubKey, createdAt, tags, content, sig) + BlockedRelayListEvent.KIND -> BlockedRelayListEvent(id, pubKey, createdAt, tags, content, sig) + BlossomServersEvent.KIND -> BlossomServersEvent(id, pubKey, createdAt, tags, content, sig) + BlossomAuthorizationEvent.KIND -> BlossomAuthorizationEvent(id, pubKey, createdAt, tags, content, sig) + BroadcastRelayListEvent.KIND -> BroadcastRelayListEvent(id, pubKey, createdAt, tags, content, sig) + BookmarkListEvent.KIND -> BookmarkListEvent(id, pubKey, createdAt, tags, content, sig) + OldBookmarkListEvent.KIND -> OldBookmarkListEvent(id, pubKey, createdAt, tags, content, sig) + CalendarDateSlotEvent.KIND -> CalendarDateSlotEvent(id, pubKey, createdAt, tags, content, sig) + CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig) + CalendarTimeSlotEvent.KIND -> CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig) + CalendarRSVPEvent.KIND -> CalendarRSVPEvent(id, pubKey, createdAt, tags, content, sig) + CallAnswerEvent.KIND -> CallAnswerEvent(id, pubKey, createdAt, tags, content, sig) + CallHangupEvent.KIND -> CallHangupEvent(id, pubKey, createdAt, tags, content, sig) + CallIceCandidateEvent.KIND -> CallIceCandidateEvent(id, pubKey, createdAt, tags, content, sig) + CallOfferEvent.KIND -> CallOfferEvent(id, pubKey, createdAt, tags, content, sig) + CallRejectEvent.KIND -> CallRejectEvent(id, pubKey, createdAt, tags, content, sig) + CallRenegotiateEvent.KIND -> CallRenegotiateEvent(id, pubKey, createdAt, tags, content, sig) + CashuMintEvent.KIND -> CashuMintEvent(id, pubKey, createdAt, tags, content, sig) + CashuMintQuoteEvent.KIND -> CashuMintQuoteEvent(id, pubKey, createdAt, tags, content, sig) + CashuTokenEvent.KIND -> CashuTokenEvent(id, pubKey, createdAt, tags, content, sig) + CashuSpendingHistoryEvent.KIND -> CashuSpendingHistoryEvent(id, pubKey, createdAt, tags, content, sig) + CashuWalletEvent.KIND -> CashuWalletEvent(id, pubKey, createdAt, tags, content, sig) + ChatEvent.KIND -> ChatEvent(id, pubKey, createdAt, tags, content, sig) + PutUserEvent.KIND -> PutUserEvent(id, pubKey, createdAt, tags, content, sig) + RemoveUserEvent.KIND -> RemoveUserEvent(id, pubKey, createdAt, tags, content, sig) + EditMetadataEvent.KIND -> EditMetadataEvent(id, pubKey, createdAt, tags, content, sig) + DeleteEventEvent.KIND -> DeleteEventEvent(id, pubKey, createdAt, tags, content, sig) + CreateGroupEvent.KIND -> CreateGroupEvent(id, pubKey, createdAt, tags, content, sig) + DeleteGroupEvent.KIND -> DeleteGroupEvent(id, pubKey, createdAt, tags, content, sig) + CreateInviteEvent.KIND -> CreateInviteEvent(id, pubKey, createdAt, tags, content, sig) + JoinRequestEvent.KIND -> JoinRequestEvent(id, pubKey, createdAt, tags, content, sig) + LeaveRequestEvent.KIND -> LeaveRequestEvent(id, pubKey, createdAt, tags, content, sig) + GroupMetadataEvent.KIND -> GroupMetadataEvent(id, pubKey, createdAt, tags, content, sig) + GroupAdminsEvent.KIND -> GroupAdminsEvent(id, pubKey, createdAt, tags, content, sig) + GroupMembersEvent.KIND -> GroupMembersEvent(id, pubKey, createdAt, tags, content, sig) + SupportedRolesEvent.KIND -> SupportedRolesEvent(id, pubKey, createdAt, tags, content, sig) + ChessGameEvent.KIND -> ChessGameEvent(id, pubKey, createdAt, tags, content, sig) + CodeSnippetEvent.KIND -> CodeSnippetEvent(id, pubKey, createdAt, tags, content, sig) + RelayFeedsListEvent.KIND -> RelayFeedsListEvent(id, pubKey, createdAt, tags, content, sig) + JesterEvent.KIND -> JesterEvent(id, pubKey, createdAt, tags, content, sig) + LiveChessGameChallengeEvent.KIND -> LiveChessGameChallengeEvent(id, pubKey, createdAt, tags, content, sig) + LiveChessGameAcceptEvent.KIND -> LiveChessGameAcceptEvent(id, pubKey, createdAt, tags, content, sig) + LiveChessMoveEvent.KIND -> LiveChessMoveEvent(id, pubKey, createdAt, tags, content, sig) + LiveChessGameEndEvent.KIND -> LiveChessGameEndEvent(id, pubKey, createdAt, tags, content, sig) + LiveChessDrawOfferEvent.KIND -> LiveChessDrawOfferEvent(id, pubKey, createdAt, tags, content, sig) + ChannelCreateEvent.KIND -> ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig) + ChannelHideMessageEvent.KIND -> ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig) + ChannelListEvent.KIND -> ChannelListEvent(id, pubKey, createdAt, tags, content, sig) + ChannelMessageEvent.KIND -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig) + ChannelMetadataEvent.KIND -> ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig) + ChannelMuteUserEvent.KIND -> ChannelMuteUserEvent(id, pubKey, createdAt, tags, content, sig) + ChatMessageEncryptedFileHeaderEvent.KIND -> ChatMessageEncryptedFileHeaderEvent(id.ifBlank { EventHasher.hashId(pubKey, createdAt, kind, tags, content) }, pubKey, createdAt, tags, content, sig) + ChatMessageEvent.KIND -> ChatMessageEvent(id.ifBlank { EventHasher.hashId(pubKey, createdAt, kind, tags, content) }, pubKey, createdAt, tags, content, sig) + ChatMessageRelayListEvent.KIND -> ChatMessageRelayListEvent(id, pubKey, createdAt, tags, content, sig) + ClassifiedsEvent.KIND -> ClassifiedsEvent(id, pubKey, createdAt, tags, content, sig) + CommentEvent.KIND -> CommentEvent(id, pubKey, createdAt, tags, content, sig) + CommunityDefinitionEvent.KIND -> CommunityDefinitionEvent(id, pubKey, createdAt, tags, content, sig) + CommunityListEvent.KIND -> CommunityListEvent(id, pubKey, createdAt, tags, content, sig) + CommunityPostApprovalEvent.KIND -> CommunityPostApprovalEvent(id, pubKey, createdAt, tags, content, sig) + ContactListEvent.KIND -> ContactListEvent(id, pubKey, createdAt, tags, content, sig) + DeletionEvent.KIND -> DeletionEvent(id, pubKey, createdAt, tags, content, sig) + DraftWrapEvent.KIND -> DraftWrapEvent(id, pubKey, createdAt, tags, content, sig) + EmojiPackEvent.KIND -> EmojiPackEvent(id, pubKey, createdAt, tags, content, sig) + EmojiPackSelectionEvent.KIND -> EmojiPackSelectionEvent(id, pubKey, createdAt, tags, content, sig) + EphemeralChatEvent.KIND -> EphemeralChatEvent(id, pubKey, createdAt, tags, content, sig) + EphemeralChatListEvent.KIND -> EphemeralChatListEvent(id, pubKey, createdAt, tags, content, sig) + ExternalIdentitiesEvent.KIND -> ExternalIdentitiesEvent(id, pubKey, createdAt, tags, content, sig) + FedimintEvent.KIND -> FedimintEvent(id, pubKey, createdAt, tags, content, sig) + FileHeaderEvent.KIND -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig) + ProfileGalleryEntryEvent.KIND -> ProfileGalleryEntryEvent(id, pubKey, createdAt, tags, content, sig) + FileServersEvent.KIND -> FileServersEvent(id, pubKey, createdAt, tags, content, sig) + FileStorageEvent.KIND -> FileStorageEvent(id, pubKey, createdAt, tags, content, sig) + FileStorageHeaderEvent.KIND -> FileStorageHeaderEvent(id, pubKey, createdAt, tags, content, sig) + FhirResourceEvent.KIND -> FhirResourceEvent(id, pubKey, createdAt, tags, content, sig) + FollowListEvent.KIND -> FollowListEvent(id, pubKey, createdAt, tags, content, sig) + GenericRepostEvent.KIND -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig) + GeohashListEvent.KIND -> GeohashListEvent(id, pubKey, createdAt, tags, content, sig) + GiftWrapEvent.KIND -> GiftWrapEvent(id, pubKey, createdAt, tags, content, sig) + EphemeralGiftWrapEvent.KIND -> EphemeralGiftWrapEvent(id, pubKey, createdAt, tags, content, sig) + GitAuthorListEvent.KIND -> GitAuthorListEvent(id, pubKey, createdAt, tags, content, sig) + GitRepositoryListEvent.KIND -> GitRepositoryListEvent(id, pubKey, createdAt, tags, content, sig) + GitIssueEvent.KIND -> GitIssueEvent(id, pubKey, createdAt, tags, content, sig) + GitReplyEvent.KIND -> GitReplyEvent(id, pubKey, createdAt, tags, content, sig) + GitPatchEvent.KIND -> GitPatchEvent(id, pubKey, createdAt, tags, content, sig) + GitRepositoryEvent.KIND -> GitRepositoryEvent(id, pubKey, createdAt, tags, content, sig) + GoodWikiAuthorListEvent.KIND -> GoodWikiAuthorListEvent(id, pubKey, createdAt, tags, content, sig) + GoodWikiRelayListEvent.KIND -> GoodWikiRelayListEvent(id, pubKey, createdAt, tags, content, sig) + GoalEvent.KIND -> GoalEvent(id, pubKey, createdAt, tags, content, sig) + HashtagListEvent.KIND -> HashtagListEvent(id, pubKey, createdAt, tags, content, sig) + HighlightEvent.KIND -> HighlightEvent(id, pubKey, createdAt, tags, content, sig) + HTTPAuthorizationEvent.KIND -> HTTPAuthorizationEvent(id, pubKey, createdAt, tags, content, sig) + IndexerRelayListEvent.KIND -> IndexerRelayListEvent(id, pubKey, createdAt, tags, content, sig) + InterestSetEvent.KIND -> InterestSetEvent(id, pubKey, createdAt, tags, content, sig) + InteractiveStoryPrologueEvent.KIND -> InteractiveStoryPrologueEvent(id, pubKey, createdAt, tags, content, sig) + InteractiveStorySceneEvent.KIND -> InteractiveStorySceneEvent(id, pubKey, createdAt, tags, content, sig) + InteractiveStoryReadingStateEvent.KIND -> InteractiveStoryReadingStateEvent(id, pubKey, createdAt, tags, content, sig) + LabelEvent.KIND -> LabelEvent(id, pubKey, createdAt, tags, content, sig) + KindMuteSetEvent.KIND -> KindMuteSetEvent(id, pubKey, createdAt, tags, content, sig) + LabeledBookmarkListEvent.KIND -> LabeledBookmarkListEvent(id, pubKey, createdAt, tags, content, sig) + LiveActivitiesChatMessageEvent.KIND -> LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig) + LiveActivitiesEvent.KIND -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig) + LnZapEvent.KIND -> LnZapEvent(id, pubKey, createdAt, tags, content, sig) + LnZapPaymentRequestEvent.KIND -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig) + LnZapPaymentResponseEvent.KIND -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig) + NwcInfoEvent.KIND -> NwcInfoEvent(id, pubKey, createdAt, tags, content, sig) + NwcNotificationEvent.KIND -> NwcNotificationEvent(id, pubKey, createdAt, tags, content, sig) + NwcNotificationEvent.LEGACY_KIND -> NwcNotificationEvent(id, pubKey, createdAt, tags, content, sig) + LnZapPrivateEvent.KIND -> LnZapPrivateEvent(id, pubKey, createdAt, tags, content, sig) + LnZapRequestEvent.KIND -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig) + LongTextNoteEvent.KIND -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig) + MarketplaceEvent.KIND -> MarketplaceEvent(id, pubKey, createdAt, tags, content, sig) + MeetingRoomEvent.KIND -> MeetingRoomEvent(id, pubKey, createdAt, tags, content, sig) + MeetingRoomPresenceEvent.KIND -> MeetingRoomPresenceEvent(id, pubKey, createdAt, tags, content, sig) + MeetingSpaceEvent.KIND -> MeetingSpaceEvent(id, pubKey, createdAt, tags, content, sig) + MintRecommendationEvent.KIND -> MintRecommendationEvent(id, pubKey, createdAt, tags, content, sig) + MediaFollowListEvent.KIND -> MediaFollowListEvent(id, pubKey, createdAt, tags, content, sig) + MediaStarterPackEvent.KIND -> MediaStarterPackEvent(id, pubKey, createdAt, tags, content, sig) + MetadataEvent.KIND -> MetadataEvent(id, pubKey, createdAt, tags, content, sig) + MuteListEvent.KIND -> MuteListEvent(id, pubKey, createdAt, tags, content, sig) + + // Marmot Protocol (MLS over Nostr) + KeyPackageEvent.KIND -> KeyPackageEvent(id, pubKey, createdAt, tags, content, sig) + + KeyPackageRelayListEvent.KIND -> KeyPackageRelayListEvent(id, pubKey, createdAt, tags, content, sig) + + WelcomeEvent.KIND -> WelcomeEvent(id, pubKey, createdAt, tags, content, sig) + + GroupEvent.KIND -> GroupEvent(id, pubKey, createdAt, tags, content, sig) + + NotificationRequestEvent.KIND -> NotificationRequestEvent(id, pubKey, createdAt, tags, content, sig) + + TokenRequestEvent.KIND -> TokenRequestEvent(id, pubKey, createdAt, tags, content, sig) + + TokenListEvent.KIND -> TokenListEvent(id, pubKey, createdAt, tags, content, sig) + + TokenRemovalEvent.KIND -> TokenRemovalEvent(id, pubKey, createdAt, tags, content, sig) + NamedSiteEvent.KIND -> NamedSiteEvent(id, pubKey, createdAt, tags, content, sig) + NNSEvent.KIND -> NNSEvent(id, pubKey, createdAt, tags, content, sig) + NipTextEvent.KIND -> NipTextEvent(id, pubKey, createdAt, tags, content, sig) + NutzapEvent.KIND -> NutzapEvent(id, pubKey, createdAt, tags, content, sig) + NutzapInfoEvent.KIND -> NutzapInfoEvent(id, pubKey, createdAt, tags, content, sig) + NutzapRedemptionEvent.KIND -> NutzapRedemptionEvent(id, pubKey, createdAt, tags, content, sig) + NostrConnectEvent.KIND -> NostrConnectEvent(id, pubKey, createdAt, tags, content, sig) + NIP90StatusEvent.KIND -> NIP90StatusEvent(id, pubKey, createdAt, tags, content, sig) + NIP90TextExtractionRequestEvent.KIND -> NIP90TextExtractionRequestEvent(id, pubKey, createdAt, tags, content, sig) + NIP90TextExtractionResponseEvent.KIND -> NIP90TextExtractionResponseEvent(id, pubKey, createdAt, tags, content, sig) + NIP90SummarizationRequestEvent.KIND -> NIP90SummarizationRequestEvent(id, pubKey, createdAt, tags, content, sig) + NIP90SummarizationResponseEvent.KIND -> NIP90SummarizationResponseEvent(id, pubKey, createdAt, tags, content, sig) + NIP90TranslationRequestEvent.KIND -> NIP90TranslationRequestEvent(id, pubKey, createdAt, tags, content, sig) + NIP90TranslationResponseEvent.KIND -> NIP90TranslationResponseEvent(id, pubKey, createdAt, tags, content, sig) + NIP90TextGenerationRequestEvent.KIND -> NIP90TextGenerationRequestEvent(id, pubKey, createdAt, tags, content, sig) + NIP90TextGenerationResponseEvent.KIND -> NIP90TextGenerationResponseEvent(id, pubKey, createdAt, tags, content, sig) + NIP90ImageGenerationRequestEvent.KIND -> NIP90ImageGenerationRequestEvent(id, pubKey, createdAt, tags, content, sig) + NIP90ImageGenerationResponseEvent.KIND -> NIP90ImageGenerationResponseEvent(id, pubKey, createdAt, tags, content, sig) + NIP90VideoConversionRequestEvent.KIND -> NIP90VideoConversionRequestEvent(id, pubKey, createdAt, tags, content, sig) + NIP90VideoConversionResponseEvent.KIND -> NIP90VideoConversionResponseEvent(id, pubKey, createdAt, tags, content, sig) + NIP90VideoTranslationRequestEvent.KIND -> NIP90VideoTranslationRequestEvent(id, pubKey, createdAt, tags, content, sig) + NIP90VideoTranslationResponseEvent.KIND -> NIP90VideoTranslationResponseEvent(id, pubKey, createdAt, tags, content, sig) + NIP90ImageToVideoRequestEvent.KIND -> NIP90ImageToVideoRequestEvent(id, pubKey, createdAt, tags, content, sig) + NIP90ImageToVideoResponseEvent.KIND -> NIP90ImageToVideoResponseEvent(id, pubKey, createdAt, tags, content, sig) + NIP90TextToSpeechRequestEvent.KIND -> NIP90TextToSpeechRequestEvent(id, pubKey, createdAt, tags, content, sig) + NIP90TextToSpeechResponseEvent.KIND -> NIP90TextToSpeechResponseEvent(id, pubKey, createdAt, tags, content, sig) + NIP90ContentDiscoveryRequestEvent.KIND -> NIP90ContentDiscoveryRequestEvent(id, pubKey, createdAt, tags, content, sig) + NIP90ContentDiscoveryResponseEvent.KIND -> NIP90ContentDiscoveryResponseEvent(id, pubKey, createdAt, tags, content, sig) + NIP90UserDiscoveryRequestEvent.KIND -> NIP90UserDiscoveryRequestEvent(id, pubKey, createdAt, tags, content, sig) + NIP90UserDiscoveryResponseEvent.KIND -> NIP90UserDiscoveryResponseEvent(id, pubKey, createdAt, tags, content, sig) + NIP90ContentSearchRequestEvent.KIND -> NIP90ContentSearchRequestEvent(id, pubKey, createdAt, tags, content, sig) + NIP90ContentSearchResponseEvent.KIND -> NIP90ContentSearchResponseEvent(id, pubKey, createdAt, tags, content, sig) + NIP90PeopleSearchRequestEvent.KIND -> NIP90PeopleSearchRequestEvent(id, pubKey, createdAt, tags, content, sig) + NIP90PeopleSearchResponseEvent.KIND -> NIP90PeopleSearchResponseEvent(id, pubKey, createdAt, tags, content, sig) + NIP90EventCountRequestEvent.KIND -> NIP90EventCountRequestEvent(id, pubKey, createdAt, tags, content, sig) + NIP90EventCountResponseEvent.KIND -> NIP90EventCountResponseEvent(id, pubKey, createdAt, tags, content, sig) + NIP90MalwareScanRequestEvent.KIND -> NIP90MalwareScanRequestEvent(id, pubKey, createdAt, tags, content, sig) + NIP90MalwareScanResponseEvent.KIND -> NIP90MalwareScanResponseEvent(id, pubKey, createdAt, tags, content, sig) + NIP90EventTimestampingRequestEvent.KIND -> NIP90EventTimestampingRequestEvent(id, pubKey, createdAt, tags, content, sig) + NIP90EventTimestampingResponseEvent.KIND -> NIP90EventTimestampingResponseEvent(id, pubKey, createdAt, tags, content, sig) + NIP90OpReturnRequestEvent.KIND -> NIP90OpReturnRequestEvent(id, pubKey, createdAt, tags, content, sig) + NIP90OpReturnResponseEvent.KIND -> NIP90OpReturnResponseEvent(id, pubKey, createdAt, tags, content, sig) + NIP90EventPublishScheduleRequestEvent.KIND -> NIP90EventPublishScheduleRequestEvent(id, pubKey, createdAt, tags, content, sig) + NIP90EventPublishScheduleResponseEvent.KIND -> NIP90EventPublishScheduleResponseEvent(id, pubKey, createdAt, tags, content, sig) + NIP90EventPowDelegationRequestEvent.KIND -> NIP90EventPowDelegationRequestEvent(id, pubKey, createdAt, tags, content, sig) + NIP90EventPowDelegationResponseEvent.KIND -> NIP90EventPowDelegationResponseEvent(id, pubKey, createdAt, tags, content, sig) + OtsEvent.KIND -> OtsEvent(id, pubKey, createdAt, tags, content, sig) + PaymentTargetsEvent.KIND -> PaymentTargetsEvent(id, pubKey, createdAt, tags, content, sig) + PeopleListEvent.KIND -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig) + PictureCurationSetEvent.KIND -> PictureCurationSetEvent(id, pubKey, createdAt, tags, content, sig) + P2POrderEvent.KIND -> P2POrderEvent(id, pubKey, createdAt, tags, content, sig) + PictureEvent.KIND -> PictureEvent(id, pubKey, createdAt, tags, content, sig) + PinListEvent.KIND -> PinListEvent(id, pubKey, createdAt, tags, content, sig) + ProfileBadgesEvent.KIND -> ProfileBadgesEvent(id, pubKey, createdAt, tags, content, sig) + ZapPollEvent.KIND -> ZapPollEvent(id, pubKey, createdAt, tags, content, sig) + PollEvent.KIND -> PollEvent(id, pubKey, createdAt, tags, content, sig) + PollResponseEvent.KIND -> PollResponseEvent(id, pubKey, createdAt, tags, content, sig) + ProductEvent.KIND -> ProductEvent(id, pubKey, createdAt, tags, content, sig) + PrivateDmEvent.KIND -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig) + PrivateOutboxRelayListEvent.KIND -> PrivateOutboxRelayListEvent(id, pubKey, createdAt, tags, content, sig) + ProxyRelayListEvent.KIND -> ProxyRelayListEvent(id, pubKey, createdAt, tags, content, sig) + PublicMessageEvent.KIND -> PublicMessageEvent(id, pubKey, createdAt, tags, content, sig) + ReactionEvent.KIND -> ReactionEvent(id, pubKey, createdAt, tags, content, sig) + ContactCardEvent.KIND -> ContactCardEvent(id, pubKey, createdAt, tags, content, sig) + EventAssertionEvent.KIND -> EventAssertionEvent(id, pubKey, createdAt, tags, content, sig) + AddressableAssertionEvent.KIND -> AddressableAssertionEvent(id, pubKey, createdAt, tags, content, sig) + ExternalIdAssertionEvent.KIND -> ExternalIdAssertionEvent(id, pubKey, createdAt, tags, content, sig) + RelayAddMemberEvent.KIND -> RelayAddMemberEvent(id, pubKey, createdAt, tags, content, sig) + RelayRemoveMemberEvent.KIND -> RelayRemoveMemberEvent(id, pubKey, createdAt, tags, content, sig) + RelayMembershipListEvent.KIND -> RelayMembershipListEvent(id, pubKey, createdAt, tags, content, sig) + RelayJoinRequestEvent.KIND -> RelayJoinRequestEvent(id, pubKey, createdAt, tags, content, sig) + RelayInviteRequestEvent.KIND -> RelayInviteRequestEvent(id, pubKey, createdAt, tags, content, sig) + RelayLeaveRequestEvent.KIND -> RelayLeaveRequestEvent(id, pubKey, createdAt, tags, content, sig) + RelayAuthEvent.KIND -> RelayAuthEvent(id, pubKey, createdAt, tags, content, sig) + RelayDiscoveryEvent.KIND -> RelayDiscoveryEvent(id, pubKey, createdAt, tags, content, sig) + RelayMonitorEvent.KIND -> RelayMonitorEvent(id, pubKey, createdAt, tags, content, sig) + ReleaseArtifactSetEvent.KIND -> ReleaseArtifactSetEvent(id, pubKey, createdAt, tags, content, sig) + RelaySetEvent.KIND -> RelaySetEvent(id, pubKey, createdAt, tags, content, sig) + ReportEvent.KIND -> ReportEvent(id, pubKey, createdAt, tags, content, sig) + RootSiteEvent.KIND -> RootSiteEvent(id, pubKey, createdAt, tags, content, sig) + RepostEvent.KIND -> RepostEvent(id, pubKey, createdAt, tags, content, sig) + RequestToVanishEvent.KIND -> RequestToVanishEvent(id, pubKey, createdAt, tags, content, sig) + SealedRumorEvent.KIND -> SealedRumorEvent(id, pubKey, createdAt, tags, content, sig) + SearchRelayListEvent.KIND -> SearchRelayListEvent(id, pubKey, createdAt, tags, content, sig) + SimpleGroupListEvent.KIND -> SimpleGroupListEvent(id, pubKey, createdAt, tags, content, sig) + StallEvent.KIND -> StallEvent(id, pubKey, createdAt, tags, content, sig) + StatusEvent.KIND -> StatusEvent(id, pubKey, createdAt, tags, content, sig) + TextNoteEvent.KIND -> TextNoteEvent(id, pubKey, createdAt, tags, content, sig) + ThreadEvent.KIND -> ThreadEvent(id, pubKey, createdAt, tags, content, sig) + TextNoteModificationEvent.KIND -> TextNoteModificationEvent(id, pubKey, createdAt, tags, content, sig) + TokenEvent.KIND -> TokenEvent(id, pubKey, createdAt, tags, content, sig) + TorrentEvent.KIND -> TorrentEvent(id, pubKey, createdAt, tags, content, sig) + TorrentCommentEvent.KIND -> TorrentCommentEvent(id, pubKey, createdAt, tags, content, sig) + TrustedRelayListEvent.KIND -> TrustedRelayListEvent(id, pubKey, createdAt, tags, content, sig) + TrustProviderListEvent.KIND -> TrustProviderListEvent(id, pubKey, createdAt, tags, content, sig) + VideoCurationSetEvent.KIND -> VideoCurationSetEvent(id, pubKey, createdAt, tags, content, sig) + VideoHorizontalEvent.KIND -> VideoHorizontalEvent(id, pubKey, createdAt, tags, content, sig) + VideoVerticalEvent.KIND -> VideoVerticalEvent(id, pubKey, createdAt, tags, content, sig) + VideoNormalEvent.KIND -> VideoNormalEvent(id, pubKey, createdAt, tags, content, sig) + VideoShortEvent.KIND -> VideoShortEvent(id, pubKey, createdAt, tags, content, sig) + VoiceEvent.KIND -> VoiceEvent(id, pubKey, createdAt, tags, content, sig) + VoiceReplyEvent.KIND -> VoiceReplyEvent(id, pubKey, createdAt, tags, content, sig) + WebBookmarkEvent.KIND -> WebBookmarkEvent(id, pubKey, createdAt, tags, content, sig) + WikiNoteEvent.KIND -> WikiNoteEvent(id, pubKey, createdAt, tags, content, sig) + else -> factories[kind]?.build(id, pubKey, createdAt, tags, content, sig) ?: Event(id, pubKey, createdAt, kind, tags, content, sig) } as T }