From 7020eb003c812143d5a82cc0c9b95e8d090f79ff Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 11:57:32 +0000 Subject: [PATCH] fix: per-peer SDP handling and invitePeer group context SDP payloads (offer, answer, renegotiate) are specific to individual PeerConnections and cannot be shared across peers. This commit: - Adds group-context overloads to createCallOffer, createCallAnswer, and createRenegotiate that include all member p-tags but wrap to a single target peer - Removes createGroupRenegotiate (renegotiation is always per-peer) - Updates sendRenegotiation/sendRenegotiationAnswer to take explicit peerPubKey and use per-peer wrapping with group p-tags - Updates invitePeer to include all existing group members + invitee in p-tags so the new peer sees the full group composition - Documents SDP per-peer vs sign-once distinction in NIP-AC spec https://claude.ai/code/session_013h2E7spwHDgSjunqumsYgp --- .../amethyst/service/call/CallController.kt | 6 +- .../amethyst/commons/call/CallManager.kt | 61 ++++++++++----- .../quartz/nipACWebRtcCalls/NIP-AC.md | 18 ++++- .../nipACWebRtcCalls/WebRtcCallFactory.kt | 78 ++++++++++++++----- 4 files changed, 122 insertions(+), 41 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index db8d4f106..1b5ff2725 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -362,13 +362,14 @@ class CallController( private fun onRenegotiationOfferReceived(event: CallRenegotiateEvent) { val session = webRtcSession ?: return val sdpOffer = event.sdpOffer() + val peerPubKey = event.pubKey Log.d(TAG) { "Renegotiation offer received, SDP length=${sdpOffer.length}" } scope.launch { session.setRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, sdpOffer)) session.createAnswer { sdp -> scope.launch { - callManager.sendRenegotiationAnswer(sdp.description) + callManager.sendRenegotiationAnswer(sdp.description, peerPubKey) } } } @@ -378,11 +379,12 @@ class CallController( val session = webRtcSession ?: return val state = callManager.state.value if (state !is CallState.Connected && state !is CallState.Connecting) return + val peerPubKey = callManager.currentPeerPubKey() ?: return Log.d(TAG) { "Starting renegotiation" } session.createOffer { sdp -> scope.launch { - callManager.sendRenegotiation(sdp.description) + callManager.sendRenegotiation(sdp.description, peerPubKey) } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt index cf0b5db76..08a3ec779 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -299,30 +299,46 @@ class CallManager( onRenegotiationOfferReceived?.invoke(event) } - suspend fun sendRenegotiation(sdpOffer: String) { + /** + * Sends a renegotiation offer to a specific peer. SDP is per-PeerConnection + * so this is always addressed to a single peer. In group calls the inner + * event includes `p` tags for all members for group context. + */ + suspend fun sendRenegotiation( + sdpOffer: String, + peerPubKey: HexKey, + ) { val callId = currentCallId() ?: return val peerPubKeys = currentPeerPubKeys() ?: return - if (peerPubKeys.size > 1) { - val result = factory.createGroupRenegotiate(sdpOffer, peerPubKeys, callId, signer) - result.wraps.forEach { publishEvent(it) } - } else { - val result = factory.createRenegotiate(sdpOffer, peerPubKeys.first(), callId, signer) - publishEvent(result.wrap) - } + val result = + if (peerPubKeys.size > 1) { + factory.createRenegotiate(sdpOffer, peerPubKey, peerPubKeys, callId, signer) + } else { + factory.createRenegotiate(sdpOffer, peerPubKey, callId, signer) + } + publishEvent(result.wrap) } - suspend fun sendRenegotiationAnswer(sdpAnswer: String) { + /** + * Sends a renegotiation answer to a specific peer. SDP is per-PeerConnection + * so this is always addressed to a single peer. In group calls the inner + * event includes `p` tags for all members for group context. + */ + suspend fun sendRenegotiationAnswer( + sdpAnswer: String, + peerPubKey: HexKey, + ) { val callId = currentCallId() ?: return val peerPubKeys = currentPeerPubKeys() ?: return - if (peerPubKeys.size > 1) { - val result = factory.createGroupCallAnswer(sdpAnswer, peerPubKeys, callId, signer) - result.wraps.forEach { publishEvent(it) } - } else { - val result = factory.createCallAnswer(sdpAnswer, peerPubKeys.first(), callId, signer) - publishEvent(result.wrap) - } + val result = + if (peerPubKeys.size > 1) { + factory.createCallAnswer(sdpAnswer, peerPubKey, peerPubKeys, callId, signer) + } else { + factory.createCallAnswer(sdpAnswer, peerPubKey, callId, signer) + } + publishEvent(result.wrap) } fun onPeerConnected() { @@ -339,7 +355,11 @@ class CallManager( ) } - /** Invites a new peer into the current call by sending them an offer. */ + /** + * Invites a new peer into the current call by sending them an offer. + * The inner event includes `p` tags for all existing group members plus + * the new invitee so they can see the full group composition. + */ suspend fun invitePeer( peerPubKey: HexKey, sdpOffer: String, @@ -347,16 +367,19 @@ class CallManager( val current = _state.value val callId: String val callType: CallType + val existingMembers: Set when (current) { is CallState.Connecting -> { callId = current.callId callType = current.callType + existingMembers = current.peerPubKeys + current.pendingPeerPubKeys _state.value = current.copy(pendingPeerPubKeys = current.pendingPeerPubKeys + peerPubKey) } is CallState.Connected -> { callId = current.callId callType = current.callType + existingMembers = current.allPeerPubKeys _state.value = current.copy(pendingPeerPubKeys = current.pendingPeerPubKeys + peerPubKey) } @@ -365,7 +388,9 @@ class CallManager( } } - val result = factory.createCallOffer(sdpOffer, peerPubKey, callId, callType, signer) + // All group members: existing peers + the new invitee + ourselves + val allMembers = existingMembers + peerPubKey + signer.pubKey + val result = factory.createCallOffer(sdpOffer, peerPubKey, allMembers, callId, callType, signer) publishEvent(result.wrap) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md index 3fe368952..f37ced8b8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md @@ -263,7 +263,7 @@ ICE candidates (kind 25052) remain addressed to a single peer because WebRTC con ### Sign Once, Wrap Per Recipient -Because all group members are listed in the inner event's `p` tags, the event is **signed once** and then gift-wrapped individually for each recipient: +For events whose content is identical for all recipients (hangup, reject), the event is **signed once** and then gift-wrapped individually for each recipient: 1. **Build** the signaling event with `p` tags for all group members 2. **Sign** the event once with the sender's key @@ -272,6 +272,18 @@ Because all group members are listed in the inner event's `p` tags, the event is This is more efficient than signing a separate event per recipient and ensures cryptographic consistency — every member receives the exact same signed inner event. +### Per-Peer SDP with Group P-Tags + +Events carrying SDP payloads (offer, answer, renegotiate) contain session descriptions that are specific to a single `PeerConnection`. In a full-mesh group call, each participant maintains a separate `PeerConnection` per peer, so SDP content differs per connection. + +For these events, the inner event still includes `p` tags for **all** group members (so any recipient can see the full group), but: + +1. **Build** the event with `p` tags for all group members and the per-peer SDP content +2. **Sign** the event (signed per peer, since the SDP content differs) +3. **Gift-wrap** and send **only to the specific peer** the SDP is intended for + +This means offer, answer, and renegotiate events in group calls are signed per-peer but still carry the full group membership in their `p` tags. + ### Group Call Offer The Call Offer (kind 25050) initiating a group call contains multiple `p` tags: @@ -294,6 +306,10 @@ The Call Offer (kind 25050) initiating a group call contains multiple `p` tags: Recipients detect a group call by the presence of multiple `p` tags. The full group is the union of all `p`-tagged pubkeys plus the event's `pubkey` (the caller). +### Inviting New Peers + +To invite a new peer into an active group call, send a Call Offer (kind 25050) with `p` tags listing **all** existing group members plus the new invitee. This allows the invitee to immediately see the full group composition. The SDP in the offer is specific to the new PeerConnection being established, so the wrap is addressed only to the invitee. + ## Spam Prevention Clients SHOULD implement call filtering: diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/WebRtcCallFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/WebRtcCallFactory.kt index e31ccf0b4..d6d7f7e34 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/WebRtcCallFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/WebRtcCallFactory.kt @@ -64,6 +64,26 @@ class WebRtcCallFactory { return Result(signed, wrap) } + /** + * Offer with group context. The inner event includes `p` tags for + * **all** group members so the invitee knows the full group, but the + * SDP is specific to one [PeerConnection][org.webrtc.PeerConnection] + * so the wrap is addressed only to [calleePubKey]. + */ + suspend fun createCallOffer( + sdpOffer: String, + calleePubKey: HexKey, + memberPubKeys: Set, + callId: String, + callType: CallType, + signer: NostrSigner, + ): Result { + val template = CallOfferEvent.build(sdpOffer, memberPubKeys, callId, callType) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = calleePubKey, expirationDelta = WRAP_EXPIRATION_SECONDS) + return Result(signed, wrap) + } + suspend fun createCallAnswer( sdpAnswer: String, callerPubKey: HexKey, @@ -76,6 +96,25 @@ class WebRtcCallFactory { return Result(signed, wrap) } + /** + * Answer with group context. The inner event includes `p` tags for + * **all** group members, but the SDP is specific to one + * [PeerConnection][org.webrtc.PeerConnection] so the wrap is addressed + * only to [peerPubKey]. + */ + suspend fun createCallAnswer( + sdpAnswer: String, + peerPubKey: HexKey, + memberPubKeys: Set, + callId: String, + signer: NostrSigner, + ): Result { + val template = CallAnswerEvent.build(sdpAnswer, memberPubKeys, callId) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey, expirationDelta = WRAP_EXPIRATION_SECONDS) + return Result(signed, wrap) + } + suspend fun createIceCandidate( candidateJson: String, peerPubKey: HexKey, @@ -124,6 +163,25 @@ class WebRtcCallFactory { return Result(signed, wrap) } + /** + * Renegotiation with group context. The inner event includes `p` tags + * for **all** group members, but the SDP is specific to one + * [PeerConnection][org.webrtc.PeerConnection] so the wrap is addressed + * only to [peerPubKey]. + */ + suspend fun createRenegotiate( + sdpOffer: String, + peerPubKey: HexKey, + memberPubKeys: Set, + callId: String, + signer: NostrSigner, + ): Result { + val template = CallRenegotiateEvent.build(sdpOffer, memberPubKeys, callId) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey, expirationDelta = WRAP_EXPIRATION_SECONDS) + return Result(signed, wrap) + } + // ---- Group call methods (multiple recipients) ---- /** @@ -206,24 +264,4 @@ class WebRtcCallFactory { } return GroupResult(signed, wraps) } - - /** - * Sends a renegotiation offer to every peer in a group call. The signed - * inner event contains `p` tags for **every** member so each recipient - * knows the full group. A separate [GiftWrapEvent] is produced for each member. - */ - suspend fun createGroupRenegotiate( - sdpOffer: String, - memberPubKeys: Set, - callId: String, - signer: NostrSigner, - ): GroupResult { - val template = CallRenegotiateEvent.build(sdpOffer, memberPubKeys, callId) - val signed = signer.sign(template) - val wraps = - memberPubKeys.map { pubKey -> - GiftWrapEvent.create(event = signed, recipientPubKey = pubKey, expirationDelta = WRAP_EXPIRATION_SECONDS) - } - return GroupResult(signed, wraps) - } }