diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingRoomEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingRoomEvent.kt index 2f2e48ffa..d01eff0d5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingRoomEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingRoomEvent.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces import androidx.compose.runtime.Immutable 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.core.any import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider @@ -30,10 +31,13 @@ import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.MeetingSpaceTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.CurrentParticipantsTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.EndsTag @@ -46,6 +50,8 @@ import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StreamingTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.TotalParticipantsTag import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid @Immutable class MeetingRoomEvent( @@ -157,5 +163,24 @@ class MeetingRoomEvent( val tags = arrayOf(AltTag.assemble(ALT)) return signer.sign(createdAt, KIND, tags, "") } + + @OptIn(ExperimentalUuidApi::class) + fun build( + meetingSpace: MeetingSpaceTag, + title: String, + starts: Long, + status: StatusTag.STATUS, + dTag: String = Uuid.random().toString(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + dTag(dTag) + meetingSpace(meetingSpace) + title(title) + starts(starts) + status(status) + alt(ALT) + initializer() + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingRoomTagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingRoomTagArrayBuilderExt.kt new file mode 100644 index 000000000..a8ef87d85 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingRoomTagArrayBuilderExt.kt @@ -0,0 +1,68 @@ +/* + * 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. + */ +@file:JvmName("MeetingRoomTagArrayBuilderExt") + +package com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces + +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.nip23LongContent.tags.ImageTag +import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag +import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.MeetingSpaceTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.CurrentParticipantsTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.EndsTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StartsTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.TotalParticipantsTag +import kotlin.jvm.JvmName + +fun TagArrayBuilder.meetingSpace(rep: MeetingSpaceTag) = addUnique(rep.toTagArray()) + +fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.summary(summary: String) = addUnique(SummaryTag.assemble(summary)) + +fun TagArrayBuilder.image(imageUrl: String) = addUnique(ImageTag.assemble(imageUrl)) + +fun TagArrayBuilder.starts(timestamp: Long) = addUnique(StartsTag.assemble(timestamp)) + +fun TagArrayBuilder.ends(timestamp: Long) = addUnique(EndsTag.assemble(timestamp)) + +fun TagArrayBuilder.status(status: StatusTag.STATUS) = addUnique(StatusTag.assemble(status)) + +fun TagArrayBuilder.currentParticipants(count: Int) = addUnique(CurrentParticipantsTag.assemble(count)) + +fun TagArrayBuilder.totalParticipants(count: Int) = addUnique(TotalParticipantsTag.assemble(count)) + +fun TagArrayBuilder.participant( + pubKey: HexKey, + relayHint: NormalizedRelayUrl? = null, + role: String? = null, + proof: HexKey? = null, +) = add(ParticipantTag.assemble(pubKey, relayHint?.url, role, proof)) + +fun TagArrayBuilder.participants(participants: List) = + participants.forEach { + participant(it.pubKey, it.relayHint, it.role, it.proof) + } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEvent.kt index c315c8703..9f4672b26 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEvent.kt @@ -23,12 +23,16 @@ package com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces import androidx.compose.runtime.Immutable 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.core.any import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.EndpointUrlTag import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.RelayListTag import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.RoomNameTag @@ -36,6 +40,8 @@ import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.ServiceUr import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid @Immutable class MeetingSpaceEvent( @@ -93,5 +99,24 @@ class MeetingSpaceEvent( val tags = arrayOf(AltTag.assemble(ALT)) return signer.sign(createdAt, KIND, tags, "") } + + @OptIn(ExperimentalUuidApi::class) + fun build( + room: String, + status: StatusTag.STATUS, + service: String, + host: ParticipantTag, + dTag: String = Uuid.random().toString(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + dTag(dTag) + room(room) + status(status) + service(service) + participant(host.pubKey, host.relayHint, host.role, host.proof) + alt(ALT) + initializer() + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/TagArrayBuilderExt.kt new file mode 100644 index 000000000..eca9aa1c9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/TagArrayBuilderExt.kt @@ -0,0 +1,62 @@ +/* + * 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. + */ +@file:JvmName("MeetingSpaceTagArrayBuilderExt") + +package com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces + +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.nip23LongContent.tags.ImageTag +import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.EndpointUrlTag +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.RelayListTag +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.RoomNameTag +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.ServiceUrlTag +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag +import kotlin.jvm.JvmName + +fun TagArrayBuilder.room(name: String) = addUnique(RoomNameTag.assemble(name)) + +fun TagArrayBuilder.summary(summary: String) = addUnique(SummaryTag.assemble(summary)) + +fun TagArrayBuilder.image(imageUrl: String) = addUnique(ImageTag.assemble(imageUrl)) + +fun TagArrayBuilder.status(status: StatusTag.STATUS) = addUnique(StatusTag.assemble(status)) + +fun TagArrayBuilder.service(url: String) = addUnique(ServiceUrlTag.assemble(url)) + +fun TagArrayBuilder.endpoint(url: String) = addUnique(EndpointUrlTag.assemble(url)) + +fun TagArrayBuilder.relays(urls: List) = addUnique(RelayListTag.assemble(urls)) + +fun TagArrayBuilder.participant( + pubKey: HexKey, + relayHint: NormalizedRelayUrl? = null, + role: String? = null, + proof: HexKey? = null, +) = add(ParticipantTag.assemble(pubKey, relayHint?.url, role, proof)) + +fun TagArrayBuilder.participants(participants: List) = + participants.forEach { + participant(it.pubKey, it.relayHint, it.role, it.proof) + } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/MeetingRoomPresenceEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/MeetingRoomPresenceEvent.kt index fa87e1c2f..f887bded2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/MeetingRoomPresenceEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/MeetingRoomPresenceEvent.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.quartz.nip53LiveActivities.presence import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Address -import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider @@ -43,7 +43,7 @@ class MeetingRoomPresenceEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig), AddressHintProvider { override fun addressHints(): List = tags.mapNotNull(MeetingSpaceTag::parseAsHint) @@ -57,20 +57,11 @@ class MeetingRoomPresenceEvent( const val KIND = 10312 const val ALT = "Room Presence tag" - fun createAddress( - pubKey: HexKey, - dtag: String, - ): Address = Address(KIND, pubKey, dtag) + fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, FIXED_D_TAG) - fun createAddressATag( - pubKey: HexKey, - dtag: String, - ): ATag = ATag(KIND, pubKey, dtag, null) + fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, FIXED_D_TAG, null) - fun createAddressTag( - pubKey: HexKey, - dtag: String, - ): String = Address.assemble(KIND, pubKey, dtag) + fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG) fun build( root: MeetingRoomEvent, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt index 67754e328..61418a5b3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt @@ -23,15 +23,19 @@ package com.vitorpamplona.quartz.nip53LiveActivities.streaming import androidx.compose.runtime.Immutable 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.core.any import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.CurrentParticipantsTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.EndsTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag @@ -43,6 +47,8 @@ import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StreamingTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.TotalParticipantsTag import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid @Immutable class LiveActivitiesEvent( @@ -154,5 +160,16 @@ class LiveActivitiesEvent( val tags = arrayOf(AltTag.assemble(ALT)) return signer.sign(createdAt, KIND, tags, "") } + + @OptIn(ExperimentalUuidApi::class) + fun build( + dTag: String = Uuid.random().toString(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + dTag(dTag) + alt(ALT) + initializer() + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/ProofOfAgreement.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/ProofOfAgreement.kt new file mode 100644 index 000000000..34bf6d069 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/ProofOfAgreement.kt @@ -0,0 +1,114 @@ +/* + * 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.nip53LiveActivities.streaming + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag +import com.vitorpamplona.quartz.utils.sha256.sha256 + +/** + * NIP-53 Proof of Agreement to Participate. + * + * The proof is a schnorr signature by the participant's private key over the + * SHA256 of the event's addressable `a` tag value (`kind:pubkey:dTag`), encoded as hex. + * + * See: https://github.com/nostr-protocol/nips/blob/master/53.md#proof-of-agreement-to-participate + */ +object ProofOfAgreement { + /** + * Computes the SHA256 digest over the canonical `kind:pubkey:dTag` string. + * This is the message that the participant's private key signs. + */ + fun digest( + eventKind: Int, + eventAuthor: HexKey, + eventDTag: String, + ): ByteArray = sha256(Address.assemble(eventKind, eventAuthor, eventDTag).encodeToByteArray()) + + fun digest(address: Address): ByteArray = digest(address.kind, address.pubKeyHex, address.dTag) + + /** + * Signs the proof using the participant's private key (schnorr over the SHA256 + * digest of the event's `a` tag value). Returns the hex-encoded signature. + * + * Only usable by the participant — requires access to their raw private key + * (e.g. from a local `NostrSignerInternal`). External signers (NIP-46, NIP-55) + * do not expose a raw-bytes sign API so they cannot produce this proof. + */ + fun sign( + participantPrivKey: ByteArray, + eventKind: Int, + eventAuthor: HexKey, + eventDTag: String, + ): HexKey = Nip01Crypto.sign(digest(eventKind, eventAuthor, eventDTag), participantPrivKey).toHexKey() + + /** + * Verifies that [proof] is a valid schnorr signature by [participantPubKey] over + * SHA256(`kind:pubkey:dTag`). Returns `false` if the proof is malformed, the + * pubkey is invalid, or the signature doesn't match. + */ + fun verify( + proof: HexKey, + participantPubKey: HexKey, + eventKind: Int, + eventAuthor: HexKey, + eventDTag: String, + ): Boolean { + val signature = proof.hexToByteArrayOrNull() ?: return false + if (signature.size != 64) return false + val pubKey = participantPubKey.hexToByteArrayOrNull() ?: return false + if (pubKey.size != 32) return false + + return runCatching { + Nip01Crypto.verify(signature, digest(eventKind, eventAuthor, eventDTag), pubKey) + }.getOrDefault(false) + } + + fun verify( + proof: HexKey, + participantPubKey: HexKey, + address: Address, + ): Boolean = verify(proof, participantPubKey, address.kind, address.pubKeyHex, address.dTag) + + /** + * Verifies a participant tag's proof against its owning [LiveActivitiesEvent]. + * Returns `false` if the tag has no proof or the proof is invalid. + */ + fun verify( + tag: ParticipantTag, + event: LiveActivitiesEvent, + ): Boolean { + val proof = tag.proof ?: return false + return verify(proof, tag.pubKey, event.address()) + } +} + +/** + * Convenience: check whether a participant's proof of agreement is valid for this event. + * Useful for UI that wants to distinguish "verified" participants from "invited" ones + * (per NIP-53: "Clients MAY only display participants if the proof is available or + * MAY display participants as 'invited' if the proof is not available"). + */ +fun LiveActivitiesEvent.hasValidProof(participant: ParticipantTag): Boolean = ProofOfAgreement.verify(participant, this) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/TagArrayBuilderExt.kt new file mode 100644 index 000000000..9d842c2c2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/TagArrayBuilderExt.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.nip53LiveActivities.streaming + +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.nip23LongContent.tags.ImageTag +import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag +import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.CurrentParticipantsTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.EndsTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.PinnedEventTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.RecordingTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.RelayListTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StartsTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StreamingTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.TotalParticipantsTag + +fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.summary(summary: String) = addUnique(SummaryTag.assemble(summary)) + +fun TagArrayBuilder.image(imageUrl: String) = addUnique(ImageTag.assemble(imageUrl)) + +fun TagArrayBuilder.streaming(url: String) = addUnique(StreamingTag.assemble(url)) + +fun TagArrayBuilder.recording(url: String) = addUnique(RecordingTag.assemble(url)) + +fun TagArrayBuilder.starts(timestamp: Long) = addUnique(StartsTag.assemble(timestamp)) + +fun TagArrayBuilder.ends(timestamp: Long) = addUnique(EndsTag.assemble(timestamp)) + +fun TagArrayBuilder.status(status: StatusTag.STATUS) = addUnique(StatusTag.assemble(status)) + +fun TagArrayBuilder.currentParticipants(count: Int) = addUnique(CurrentParticipantsTag.assemble(count)) + +fun TagArrayBuilder.totalParticipants(count: Int) = addUnique(TotalParticipantsTag.assemble(count)) + +fun TagArrayBuilder.participant( + pubKey: HexKey, + relayHint: NormalizedRelayUrl? = null, + role: String? = null, + proof: HexKey? = null, +) = add(ParticipantTag.assemble(pubKey, relayHint?.url, role, proof)) + +fun TagArrayBuilder.participants(participants: List) = + participants.forEach { + participant(it.pubKey, it.relayHint, it.role, it.proof) + } + +fun TagArrayBuilder.relays(urls: List) = addUnique(RelayListTag.assemble(urls)) + +fun TagArrayBuilder.pinned(eventId: HexKey) = add(PinnedEventTag.assemble(eventId)) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEventBuildTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEventBuildTest.kt new file mode 100644 index 000000000..63fa5a82d --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEventBuildTest.kt @@ -0,0 +1,102 @@ +/* + * 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.nip53LiveActivities.meetingSpaces + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.MeetingSpaceTag +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag as StreamStatusTag + +class MeetingSpaceEventBuildTest { + private val host = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + @Test + fun meetingSpaceBuildAssemblesRequiredTags() { + val template = + MeetingSpaceEvent.build( + room = "Main Hall", + status = StatusTag.STATUS.OPEN, + service = "https://meet.example.com/hall", + host = ParticipantTag(host, null, "Host", null), + dTag = "main-hall", + createdAt = 1_700_000_000L, + ) { + summary("The big room") + image("https://example.com/hall.png") + endpoint("https://api.example.com/hall") + } + + assertEquals(MeetingSpaceEvent.KIND, template.kind) + val byName = template.tags.groupBy { it[0] } + + assertEquals("main-hall", byName["d"]?.single()?.get(1)) + assertEquals("Main Hall", byName["room"]?.single()?.get(1)) + assertEquals("open", byName["status"]?.single()?.get(1)) + assertEquals("https://meet.example.com/hall", byName["service"]?.single()?.get(1)) + assertEquals("https://api.example.com/hall", byName["endpoint"]?.single()?.get(1)) + assertEquals("The big room", byName["summary"]?.single()?.get(1)) + assertEquals("https://example.com/hall.png", byName["image"]?.single()?.get(1)) + assertEquals(MeetingSpaceEvent.ALT, byName["alt"]?.single()?.get(1)) + + val hostTag = byName["p"]?.single() + assertNotNull(hostTag) + assertEquals(host, hostTag[1]) + assertEquals("Host", hostTag[3]) + } + + @Test + fun meetingRoomBuildReferencesParentSpace() { + val spaceAddress = Address(MeetingSpaceEvent.KIND, host, "main-hall") + val template = + MeetingRoomEvent.build( + meetingSpace = MeetingSpaceTag(spaceAddress), + title = "Annual Meeting", + starts = 1_700_000_000L, + status = StreamStatusTag.STATUS.PLANNED, + dTag = "annual-2026", + createdAt = 1_700_000_000L, + ) { + ends(1_700_003_600L) + currentParticipants(10) + totalParticipants(100) + } + + assertEquals(MeetingRoomEvent.KIND, template.kind) + val byName = template.tags.groupBy { it[0] } + + assertEquals("annual-2026", byName["d"]?.single()?.get(1)) + assertEquals("Annual Meeting", byName["title"]?.single()?.get(1)) + assertEquals("1700000000", byName["starts"]?.single()?.get(1)) + assertEquals("1700003600", byName["ends"]?.single()?.get(1)) + assertEquals("planned", byName["status"]?.single()?.get(1)) + assertEquals("10", byName["current_participants"]?.single()?.get(1)) + assertEquals("100", byName["total_participants"]?.single()?.get(1)) + assertEquals(MeetingRoomEvent.ALT, byName["alt"]?.single()?.get(1)) + + val aTag = byName["a"]?.single() + assertNotNull(aTag) + assertEquals("${MeetingSpaceEvent.KIND}:$host:main-hall", aTag[1]) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/MeetingRoomPresenceEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/MeetingRoomPresenceEventTest.kt new file mode 100644 index 000000000..44be9889f --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/MeetingRoomPresenceEventTest.kt @@ -0,0 +1,69 @@ +/* + * 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.nip53LiveActivities.presence + +import kotlin.test.Test +import kotlin.test.assertEquals + +class MeetingRoomPresenceEventTest { + private val participant = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + @Test + fun kind10312IsReplaceable() { + val event = + MeetingRoomPresenceEvent( + id = "0".repeat(64), + pubKey = participant, + createdAt = 1_700_000_000L, + tags = + arrayOf( + arrayOf("a", "30313:${"b".repeat(64)}:annual-2026"), + arrayOf("hand", "1"), + ), + content = "", + sig = "0".repeat(128), + ) + + // Per NIP-53, kind 10312 is a regular replaceable event: "presence can only + // be indicated in one room at a time". Replaceable events have a fixed empty + // d-tag regardless of what's in the event tags. + assertEquals("", event.dTag()) + assertEquals("${MeetingRoomPresenceEvent.KIND}:$participant:", event.addressTag()) + } + + @Test + fun createAddressKeysByPubKeyOnly() { + val a = MeetingRoomPresenceEvent.createAddress(participant) + assertEquals(MeetingRoomPresenceEvent.KIND, a.kind) + assertEquals(participant, a.pubKeyHex) + assertEquals("", a.dTag) + + val aTag = MeetingRoomPresenceEvent.createAddressATag(participant) + assertEquals(MeetingRoomPresenceEvent.KIND, aTag.kind) + assertEquals(participant, aTag.pubKeyHex) + assertEquals("", aTag.dTag) + + assertEquals( + "${MeetingRoomPresenceEvent.KIND}:$participant:", + MeetingRoomPresenceEvent.createAddressTag(participant), + ) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEventBuildTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEventBuildTest.kt new file mode 100644 index 000000000..83731fc9f --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEventBuildTest.kt @@ -0,0 +1,97 @@ +/* + * 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.nip53LiveActivities.streaming + +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class LiveActivitiesEventBuildTest { + private val host = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + private val guest = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + + @Test + fun buildAssemblesAllRequiredAndOptionalTags() { + val template = + LiveActivitiesEvent.build(dTag = "my-stream", createdAt = 1_700_000_000L) { + title("My Stream") + summary("A live event") + image("https://example.com/img.png") + streaming("https://example.com/stream.m3u8") + starts(1_700_000_000L) + status(StatusTag.STATUS.LIVE) + currentParticipants(42) + totalParticipants(100) + participant(host, role = "Host") + participant(guest, role = "Speaker") + } + + assertEquals(LiveActivitiesEvent.KIND, template.kind) + assertEquals("", template.content) + + val tagsByName = template.tags.groupBy { it[0] } + + assertEquals("my-stream", tagsByName["d"]?.single()?.get(1)) + assertEquals("My Stream", tagsByName["title"]?.single()?.get(1)) + assertEquals("A live event", tagsByName["summary"]?.single()?.get(1)) + assertEquals("https://example.com/img.png", tagsByName["image"]?.single()?.get(1)) + assertEquals("https://example.com/stream.m3u8", tagsByName["streaming"]?.single()?.get(1)) + assertEquals("1700000000", tagsByName["starts"]?.single()?.get(1)) + assertEquals("live", tagsByName["status"]?.single()?.get(1)) + assertEquals("42", tagsByName["current_participants"]?.single()?.get(1)) + assertEquals("100", tagsByName["total_participants"]?.single()?.get(1)) + assertEquals(LiveActivitiesEvent.ALT, tagsByName["alt"]?.single()?.get(1)) + + // Per NIP-53, role lives at index 3 of the p-tag; a missing relay must be + // represented as an empty string placeholder, not dropped. + val pTags = tagsByName["p"] ?: emptyList() + assertEquals(2, pTags.size) + assertContentEquals(arrayOf("p", host, "", "Host"), pTags[0]) + assertContentEquals(arrayOf("p", guest, "", "Speaker"), pTags[1]) + } + + @Test + fun buildAllowsMultiplePinnedAndHashtagsWithoutClobbering() { + val template = + LiveActivitiesEvent.build(dTag = "my-stream") { + title("title") + pinned("1".repeat(64)) + pinned("2".repeat(64)) + } + + val pinned = template.tags.filter { it[0] == "pinned" } + assertEquals(2, pinned.size) + } + + @Test + fun buildUsesRandomDTagWhenNotProvided() { + val a = LiveActivitiesEvent.build { title("a") } + val b = LiveActivitiesEvent.build { title("b") } + val aDTag = a.tags.first { it[0] == "d" }[1] + val bDTag = b.tags.first { it[0] == "d" }[1] + assertNotNull(aDTag) + assertTrue(aDTag.isNotEmpty()) + assertTrue(aDTag != bDTag, "random dTag should differ between builds") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/ProofOfAgreementTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/ProofOfAgreementTest.kt new file mode 100644 index 000000000..bb4bbc313 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/ProofOfAgreementTest.kt @@ -0,0 +1,204 @@ +/* + * 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.nip53LiveActivities.streaming + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ProofOfAgreementTest { + private val dummySig = "0".repeat(128) + + @Test + fun roundTripSignAndVerify() { + val host = KeyPair() + val participant = KeyPair() + val hostHex = host.pubKey.toHexKey() + val participantHex = participant.pubKey.toHexKey() + val dTag = "my-stream" + + val proof = + ProofOfAgreement.sign( + participantPrivKey = participant.privKey!!, + eventKind = LiveActivitiesEvent.KIND, + eventAuthor = hostHex, + eventDTag = dTag, + ) + + assertTrue( + ProofOfAgreement.verify( + proof = proof, + participantPubKey = participantHex, + eventKind = LiveActivitiesEvent.KIND, + eventAuthor = hostHex, + eventDTag = dTag, + ), + ) + } + + @Test + fun verifyFailsForDifferentAddress() { + val host = KeyPair() + val participant = KeyPair() + val hostHex = host.pubKey.toHexKey() + val participantHex = participant.pubKey.toHexKey() + + val proof = + ProofOfAgreement.sign( + participantPrivKey = participant.privKey!!, + eventKind = LiveActivitiesEvent.KIND, + eventAuthor = hostHex, + eventDTag = "one", + ) + + assertFalse( + ProofOfAgreement.verify( + proof = proof, + participantPubKey = participantHex, + eventKind = LiveActivitiesEvent.KIND, + eventAuthor = hostHex, + eventDTag = "another", + ), + ) + } + + @Test + fun verifyFailsForWrongParticipantKey() { + val host = KeyPair() + val participant = KeyPair() + val imposter = KeyPair() + val hostHex = host.pubKey.toHexKey() + + val proof = + ProofOfAgreement.sign( + participantPrivKey = participant.privKey!!, + eventKind = LiveActivitiesEvent.KIND, + eventAuthor = hostHex, + eventDTag = "stream", + ) + + assertFalse( + ProofOfAgreement.verify( + proof = proof, + participantPubKey = imposter.pubKey.toHexKey(), + eventKind = LiveActivitiesEvent.KIND, + eventAuthor = hostHex, + eventDTag = "stream", + ), + ) + } + + @Test + fun verifyFailsForMalformedProof() { + assertFalse( + ProofOfAgreement.verify( + proof = "not-hex", + participantPubKey = "a".repeat(64), + eventKind = LiveActivitiesEvent.KIND, + eventAuthor = "a".repeat(64), + eventDTag = "x", + ), + ) + assertFalse( + ProofOfAgreement.verify( + proof = "ab", + participantPubKey = "a".repeat(64), + eventKind = LiveActivitiesEvent.KIND, + eventAuthor = "a".repeat(64), + eventDTag = "x", + ), + ) + } + + @Test + fun verifyAgainstLiveActivitiesEvent() { + val host = KeyPair() + val participant = KeyPair() + val hostHex = host.pubKey.toHexKey() + val participantHex = participant.pubKey.toHexKey() + val dTag = "event-d" + + val proof = + ProofOfAgreement.sign( + participantPrivKey = participant.privKey!!, + eventKind = LiveActivitiesEvent.KIND, + eventAuthor = hostHex, + eventDTag = dTag, + ) + + val event = + LiveActivitiesEvent( + id = "0".repeat(64), + pubKey = hostHex, + createdAt = 1_700_000_000L, + tags = + arrayOf( + arrayOf("d", dTag), + arrayOf("p", participantHex, "", "Speaker", proof), + arrayOf("p", "c".repeat(64), "", "Speaker"), + ), + content = "", + sig = dummySig, + ) + + val verifiedTag = event.participants().first { it.pubKey == participantHex } + val unverifiedTag = event.participants().first { it.pubKey == "c".repeat(64) } + + assertTrue(event.hasValidProof(verifiedTag)) + assertFalse(event.hasValidProof(unverifiedTag), "missing proof must not be treated as valid") + } + + @Test + fun digestMatchesAddressForm() { + // digest(kind, pubkey, dtag) must agree with digest(Address) + val hostHex = "a".repeat(64) + val d1 = ProofOfAgreement.digest(LiveActivitiesEvent.KIND, hostHex, "abc") + val d2 = ProofOfAgreement.digest(Address(LiveActivitiesEvent.KIND, hostHex, "abc")) + assertTrue(d1.contentEquals(d2)) + } + + @Test + fun tagWithoutProofFailsVerification() { + val host = KeyPair() + val participant = KeyPair() + + val event = + LiveActivitiesEvent( + id = "0".repeat(64), + pubKey = host.pubKey.toHexKey(), + createdAt = 1_700_000_000L, + tags = arrayOf(arrayOf("d", "d"), arrayOf("p", participant.pubKey.toHexKey())), + content = "", + sig = dummySig, + ) + + assertFalse( + ProofOfAgreement.verify( + tag = ParticipantTag(participant.pubKey.toHexKey(), null, null, null), + event = event, + ), + ) + } +}