test: add NIP-AC gift wrap round-trip tests with real crypto
Verify the full encrypt/decrypt pipeline for all 6 NIP-AC signaling event types through Ephemeral Gift Wraps (kind 21059): sign inner event → NIP-44 encrypt → gift wrap → unwrap → verify Tests cover: - Each event kind round-trips (offer, answer, ICE, hangup, reject, renegotiate) - Third parties cannot decrypt wraps addressed to others - Group call per-peer wraps are only decryptable by intended recipient - "Sign once, wrap per recipient" produces identical inner event IDs - SDP and ICE candidate special characters survive JSON+NIP-44 round-trip - Ephemeral wrap keys are unique per wrap and differ from sender - Inner event signatures are verifiable after unwrapping - Full P2P call flow (all 7 signaling steps) through gift wraps Uses real secp256k1 keys and NIP-44 encryption — no mocks. https://claude.ai/code/session_01AfRYTRCvtKqqDxeKQujUrx
This commit is contained in:
+528
@@ -0,0 +1,528 @@
|
||||
/*
|
||||
* 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.nipACWebRtcCalls
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.verify
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent
|
||||
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent
|
||||
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent
|
||||
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent
|
||||
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent
|
||||
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent
|
||||
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* NIP-AC Gift Wrap Round-Trip Tests
|
||||
*
|
||||
* These tests verify the full encrypt/decrypt pipeline for NIP-AC signaling
|
||||
* events delivered via Ephemeral Gift Wraps (kind 21059):
|
||||
*
|
||||
* sign inner event → gift wrap (NIP-44 encrypt) → unwrap (NIP-44 decrypt) → verify inner event
|
||||
*
|
||||
* This is the critical interoperability boundary: if two implementations can
|
||||
* successfully unwrap each other's gift-wrapped signaling events and recover
|
||||
* the correct typed inner event, they can establish calls.
|
||||
*
|
||||
* All tests use real secp256k1 keys and NIP-44 encryption — no mocks.
|
||||
*/
|
||||
class NipACGiftWrapRoundTripTest {
|
||||
private val aliceSigner = NostrSignerInternal(KeyPair())
|
||||
private val bobSigner = NostrSignerInternal(KeyPair())
|
||||
private val carolSigner = NostrSignerInternal(KeyPair())
|
||||
|
||||
private val alice = aliceSigner.pubKey
|
||||
private val bob = bobSigner.pubKey
|
||||
private val carol = carolSigner.pubKey
|
||||
|
||||
private val callId = "550e8400-e29b-41d4-a716-446655440000"
|
||||
private val sdpOffer = "v=0\r\no=- 4611731400430051336 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0"
|
||||
private val sdpAnswer = "v=0\r\no=- 4611731400430051337 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0"
|
||||
|
||||
private val factory = WebRtcCallFactory()
|
||||
|
||||
// ========================================================================
|
||||
// 1. Call Offer: sign → wrap → unwrap → verify
|
||||
// ========================================================================
|
||||
|
||||
@Test
|
||||
fun callOffer_roundTrips_throughGiftWrap() =
|
||||
runTest {
|
||||
val result = factory.createCallOffer(sdpOffer, bob, callId, CallType.VIDEO, aliceSigner)
|
||||
|
||||
// Wrap is addressed to Bob
|
||||
val wrap = result.wrap
|
||||
assertEquals(EphemeralGiftWrapEvent.KIND, wrap.kind)
|
||||
assertEquals(bob, wrap.recipientPubKey())
|
||||
|
||||
// Wrap pubkey is ephemeral (NOT alice)
|
||||
assertNotEquals(alice, wrap.pubKey, "Gift wrap MUST use an ephemeral pubkey, not the sender's")
|
||||
|
||||
// Bob unwraps
|
||||
val inner = wrap.unwrapThrowing(bobSigner)
|
||||
assertIs<CallOfferEvent>(inner, "Deserialized inner event must be CallOfferEvent")
|
||||
assertEquals(alice, inner.pubKey, "Inner event pubkey must be the real sender (alice)")
|
||||
assertEquals(sdpOffer, inner.sdpOffer())
|
||||
assertEquals(callId, inner.callId())
|
||||
assertEquals(CallType.VIDEO, inner.callType())
|
||||
assertEquals(bob, inner.recipientPubKeys().single())
|
||||
assertTrue(inner.verify(), "Inner event signature must be valid")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun callOffer_cannotBeDecrypted_byThirdParty() =
|
||||
runTest {
|
||||
val result = factory.createCallOffer(sdpOffer, bob, callId, CallType.VOICE, aliceSigner)
|
||||
|
||||
// Carol should NOT be able to decrypt a wrap addressed to Bob
|
||||
val inner = result.wrap.unwrapOrNull(carolSigner)
|
||||
assertNull(inner, "Third party MUST NOT be able to decrypt gift wrap addressed to another user")
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 2. Call Answer: sign → wrap → unwrap → verify
|
||||
// ========================================================================
|
||||
|
||||
@Test
|
||||
fun callAnswer_roundTrips_throughGiftWrap() =
|
||||
runTest {
|
||||
val result = factory.createCallAnswer(sdpAnswer, alice, callId, bobSigner)
|
||||
|
||||
val wrap = result.wrap
|
||||
assertEquals(EphemeralGiftWrapEvent.KIND, wrap.kind)
|
||||
assertEquals(alice, wrap.recipientPubKey())
|
||||
|
||||
// Alice unwraps
|
||||
val inner = wrap.unwrapThrowing(aliceSigner)
|
||||
assertIs<CallAnswerEvent>(inner)
|
||||
assertEquals(bob, inner.pubKey)
|
||||
assertEquals(sdpAnswer, inner.sdpAnswer())
|
||||
assertEquals(callId, inner.callId())
|
||||
assertTrue(inner.verify())
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 3. ICE Candidate: sign → wrap → unwrap → verify JSON content
|
||||
// ========================================================================
|
||||
|
||||
@Test
|
||||
fun iceCandidate_roundTrips_throughGiftWrap() =
|
||||
runTest {
|
||||
val candidateJson =
|
||||
CallIceCandidateEvent.serializeCandidate(
|
||||
"candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx raddr 0.0.0.0 rport 0",
|
||||
"0",
|
||||
0,
|
||||
)
|
||||
val result = factory.createIceCandidate(candidateJson, bob, callId, aliceSigner)
|
||||
|
||||
val inner = result.wrap.unwrapThrowing(bobSigner)
|
||||
assertIs<CallIceCandidateEvent>(inner)
|
||||
assertEquals(alice, inner.pubKey)
|
||||
assertEquals(callId, inner.callId())
|
||||
assertEquals(candidateJson, inner.candidateJson())
|
||||
assertEquals("0", inner.sdpMid())
|
||||
assertEquals(0, inner.sdpMLineIndex())
|
||||
assertTrue(
|
||||
inner.candidateSdp().contains("842163049"),
|
||||
"Candidate SDP must survive JSON escaping round-trip",
|
||||
)
|
||||
assertTrue(inner.verify())
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 4. Hangup: sign → wrap → unwrap → verify
|
||||
// ========================================================================
|
||||
|
||||
@Test
|
||||
fun hangup_roundTrips_throughGiftWrap() =
|
||||
runTest {
|
||||
val result = factory.createHangup(bob, callId, "user ended", aliceSigner)
|
||||
|
||||
val inner = result.wrap.unwrapThrowing(bobSigner)
|
||||
assertIs<CallHangupEvent>(inner)
|
||||
assertEquals(alice, inner.pubKey)
|
||||
assertEquals(callId, inner.callId())
|
||||
assertEquals("user ended", inner.reason())
|
||||
assertTrue(inner.verify())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hangup_emptyReason_roundTrips() =
|
||||
runTest {
|
||||
val result = factory.createHangup(bob, callId, "", aliceSigner)
|
||||
|
||||
val inner = result.wrap.unwrapThrowing(bobSigner)
|
||||
assertIs<CallHangupEvent>(inner)
|
||||
assertNull(inner.reason(), "Empty reason should parse as null")
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 5. Reject: sign → wrap → unwrap → verify
|
||||
// ========================================================================
|
||||
|
||||
@Test
|
||||
fun reject_roundTrips_throughGiftWrap() =
|
||||
runTest {
|
||||
val result = factory.createReject(alice, callId, "", bobSigner)
|
||||
|
||||
val inner = result.wrap.unwrapThrowing(aliceSigner)
|
||||
assertIs<CallRejectEvent>(inner)
|
||||
assertEquals(bob, inner.pubKey)
|
||||
assertEquals(callId, inner.callId())
|
||||
assertTrue(inner.verify())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun busyReject_roundTrips_throughGiftWrap() =
|
||||
runTest {
|
||||
val result = factory.createReject(alice, callId, "busy", bobSigner)
|
||||
|
||||
val inner = result.wrap.unwrapThrowing(aliceSigner)
|
||||
assertIs<CallRejectEvent>(inner)
|
||||
assertEquals("busy", inner.reason())
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 6. Renegotiate: sign → wrap → unwrap → verify
|
||||
// ========================================================================
|
||||
|
||||
@Test
|
||||
fun renegotiate_roundTrips_throughGiftWrap() =
|
||||
runTest {
|
||||
val newSdp = "v=0\r\no=- 4611731400430051338 3 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0"
|
||||
val result = factory.createRenegotiate(newSdp, bob, callId, aliceSigner)
|
||||
|
||||
val inner = result.wrap.unwrapThrowing(bobSigner)
|
||||
assertIs<CallRenegotiateEvent>(inner)
|
||||
assertEquals(alice, inner.pubKey)
|
||||
assertEquals(newSdp, inner.sdpOffer())
|
||||
assertEquals(callId, inner.callId())
|
||||
assertTrue(inner.verify())
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 7. Group Call: per-peer wraps only decryptable by intended recipient
|
||||
// ========================================================================
|
||||
|
||||
@Test
|
||||
fun groupCallOffer_perPeerWraps_eachDecryptableOnlyByTarget() =
|
||||
runTest {
|
||||
val result =
|
||||
factory.createGroupCallOffer(
|
||||
sdpOffer,
|
||||
setOf(bob, carol),
|
||||
callId,
|
||||
CallType.VIDEO,
|
||||
aliceSigner,
|
||||
)
|
||||
|
||||
// Inner event has p-tags for all callees
|
||||
val inner = result.msg
|
||||
assertIs<CallOfferEvent>(inner)
|
||||
assertEquals(setOf(bob, carol), inner.recipientPubKeys())
|
||||
|
||||
// Two wraps — one per callee
|
||||
assertEquals(2, result.wraps.size)
|
||||
|
||||
for (wrap in result.wraps) {
|
||||
val recipient = wrap.recipientPubKey()
|
||||
assertNotNull(recipient)
|
||||
|
||||
val recipientSigner =
|
||||
when (recipient) {
|
||||
bob -> bobSigner
|
||||
carol -> carolSigner
|
||||
else -> error("Unexpected recipient: $recipient")
|
||||
}
|
||||
|
||||
val unwrapped = wrap.unwrapThrowing(recipientSigner)
|
||||
assertIs<CallOfferEvent>(unwrapped)
|
||||
assertEquals(alice, unwrapped.pubKey)
|
||||
assertEquals(sdpOffer, unwrapped.sdpOffer())
|
||||
assertEquals(callId, unwrapped.callId())
|
||||
assertTrue(unwrapped.verify())
|
||||
|
||||
// The OTHER member should NOT be able to decrypt this wrap
|
||||
val otherSigner = if (recipient == bob) carolSigner else bobSigner
|
||||
assertNull(
|
||||
wrap.unwrapOrNull(otherSigner),
|
||||
"Wrap for $recipient must not be decryptable by other member",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun groupCallAnswer_broadcastWraps_eachDecryptableByTarget() =
|
||||
runTest {
|
||||
val allMembers = setOf(alice, bob, carol)
|
||||
val result = factory.createGroupCallAnswer(sdpAnswer, allMembers, callId, bobSigner)
|
||||
|
||||
assertEquals(allMembers.size, result.wraps.size)
|
||||
|
||||
for (wrap in result.wraps) {
|
||||
val recipient = wrap.recipientPubKey()
|
||||
assertNotNull(recipient)
|
||||
assertTrue(recipient in allMembers)
|
||||
|
||||
val recipientSigner =
|
||||
when (recipient) {
|
||||
alice -> aliceSigner
|
||||
bob -> bobSigner
|
||||
carol -> carolSigner
|
||||
else -> error("Unexpected recipient")
|
||||
}
|
||||
|
||||
val unwrapped = wrap.unwrapThrowing(recipientSigner)
|
||||
assertIs<CallAnswerEvent>(unwrapped)
|
||||
assertEquals(bob, unwrapped.pubKey, "Inner event author should be bob (answerer)")
|
||||
assertEquals(sdpAnswer, unwrapped.sdpAnswer())
|
||||
assertEquals(allMembers, unwrapped.recipientPubKeys())
|
||||
assertTrue(unwrapped.verify())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun groupHangup_allMembers_receiveIdenticalInnerEvent() =
|
||||
runTest {
|
||||
val members = setOf(bob, carol)
|
||||
val result = factory.createGroupHangup(members, callId, "leaving", aliceSigner)
|
||||
|
||||
assertEquals(members.size, result.wraps.size)
|
||||
|
||||
// All wraps should contain the same signed inner event
|
||||
val innerIds = mutableSetOf<String>()
|
||||
for (wrap in result.wraps) {
|
||||
val recipient = wrap.recipientPubKey()!!
|
||||
val recipientSigner = if (recipient == bob) bobSigner else carolSigner
|
||||
|
||||
val unwrapped = wrap.unwrapThrowing(recipientSigner)
|
||||
assertIs<CallHangupEvent>(unwrapped)
|
||||
assertEquals(alice, unwrapped.pubKey)
|
||||
assertEquals("leaving", unwrapped.reason())
|
||||
innerIds.add(unwrapped.id)
|
||||
}
|
||||
|
||||
// "Sign once, wrap per recipient" — same inner event ID
|
||||
assertEquals(1, innerIds.size, "Group hangup must sign once and wrap per recipient (same inner event id)")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun groupReject_allMembers_receiveIdenticalInnerEvent() =
|
||||
runTest {
|
||||
val members = setOf(alice, carol)
|
||||
val result = factory.createGroupReject(members, callId, "busy", bobSigner)
|
||||
|
||||
val innerIds = mutableSetOf<String>()
|
||||
for (wrap in result.wraps) {
|
||||
val recipient = wrap.recipientPubKey()!!
|
||||
val recipientSigner = if (recipient == alice) aliceSigner else carolSigner
|
||||
|
||||
val unwrapped = wrap.unwrapThrowing(recipientSigner)
|
||||
assertIs<CallRejectEvent>(unwrapped)
|
||||
assertEquals(bob, unwrapped.pubKey)
|
||||
innerIds.add(unwrapped.id)
|
||||
}
|
||||
|
||||
assertEquals(1, innerIds.size, "Group reject must sign once and wrap per recipient")
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 8. Full P2P Call Flow: Alice calls Bob, complete gift-wrap round-trip
|
||||
// ========================================================================
|
||||
|
||||
@Test
|
||||
fun fullP2PFlow_allSignalingEvents_roundTrip() =
|
||||
runTest {
|
||||
// Step 1: Alice sends offer to Bob
|
||||
val offerResult = factory.createCallOffer(sdpOffer, bob, callId, CallType.VIDEO, aliceSigner)
|
||||
val offerInner = offerResult.wrap.unwrapThrowing(bobSigner)
|
||||
assertIs<CallOfferEvent>(offerInner)
|
||||
assertEquals(sdpOffer, offerInner.sdpOffer())
|
||||
|
||||
// Step 2: Bob sends answer to Alice
|
||||
val answerResult = factory.createCallAnswer(sdpAnswer, alice, callId, bobSigner)
|
||||
val answerInner = answerResult.wrap.unwrapThrowing(aliceSigner)
|
||||
assertIs<CallAnswerEvent>(answerInner)
|
||||
assertEquals(sdpAnswer, answerInner.sdpAnswer())
|
||||
|
||||
// Step 3: Both exchange ICE candidates
|
||||
val aliceIceJson = CallIceCandidateEvent.serializeCandidate("candidate:1 1 udp 2122260223 192.168.1.1 44323 typ host", "0", 0)
|
||||
val aliceIceResult = factory.createIceCandidate(aliceIceJson, bob, callId, aliceSigner)
|
||||
val aliceIceInner = aliceIceResult.wrap.unwrapThrowing(bobSigner)
|
||||
assertIs<CallIceCandidateEvent>(aliceIceInner)
|
||||
|
||||
val bobIceJson = CallIceCandidateEvent.serializeCandidate("candidate:2 1 udp 2122260223 192.168.1.2 44324 typ host", "0", 0)
|
||||
val bobIceResult = factory.createIceCandidate(bobIceJson, alice, callId, bobSigner)
|
||||
val bobIceInner = bobIceResult.wrap.unwrapThrowing(aliceSigner)
|
||||
assertIs<CallIceCandidateEvent>(bobIceInner)
|
||||
|
||||
// Step 4: Alice sends renegotiation (voice → video)
|
||||
val renegoResult = factory.createRenegotiate("new-video-sdp", bob, callId, aliceSigner)
|
||||
val renegoInner = renegoResult.wrap.unwrapThrowing(bobSigner)
|
||||
assertIs<CallRenegotiateEvent>(renegoInner)
|
||||
assertEquals("new-video-sdp", renegoInner.sdpOffer())
|
||||
|
||||
// Step 5: Bob answers renegotiation
|
||||
val renegoAnswerResult = factory.createCallAnswer("renego-answer-sdp", alice, callId, bobSigner)
|
||||
val renegoAnswerInner = renegoAnswerResult.wrap.unwrapThrowing(aliceSigner)
|
||||
assertIs<CallAnswerEvent>(renegoAnswerInner)
|
||||
|
||||
// Step 6: Alice hangs up
|
||||
val hangupResult = factory.createHangup(bob, callId, signer = aliceSigner)
|
||||
val hangupInner = hangupResult.wrap.unwrapThrowing(bobSigner)
|
||||
assertIs<CallHangupEvent>(hangupInner)
|
||||
assertEquals(callId, hangupInner.callId())
|
||||
|
||||
// All inner events signed by real keys, all verified
|
||||
for (event in listOf(offerInner, answerInner, aliceIceInner, bobIceInner, renegoInner, renegoAnswerInner, hangupInner)) {
|
||||
assertTrue(event.verify(), "Inner event ${event::class.simpleName} signature must be valid")
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 9. Wrap Metadata: ephemeral key, p-tag, kind
|
||||
// ========================================================================
|
||||
|
||||
@Test
|
||||
fun wrapMetadata_isCorrect() =
|
||||
runTest {
|
||||
val result = factory.createCallOffer(sdpOffer, bob, callId, CallType.VOICE, aliceSigner)
|
||||
val wrap = result.wrap
|
||||
|
||||
assertEquals(EphemeralGiftWrapEvent.KIND, wrap.kind, "Wrap must be kind 21059")
|
||||
assertNotEquals(alice, wrap.pubKey, "Wrap pubkey must be ephemeral, not sender")
|
||||
assertEquals(bob, wrap.recipientPubKey(), "Wrap p-tag must be the recipient")
|
||||
assertTrue(wrap.content.isNotEmpty(), "Wrap content must be non-empty (encrypted)")
|
||||
assertTrue(wrap.verify(), "Wrap signature must be valid (signed by ephemeral key)")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun eachWrap_usesUniqueEphemeralKey() =
|
||||
runTest {
|
||||
val result1 = factory.createCallOffer(sdpOffer, bob, callId, CallType.VOICE, aliceSigner)
|
||||
val result2 = factory.createCallOffer(sdpOffer, bob, callId, CallType.VOICE, aliceSigner)
|
||||
|
||||
assertNotEquals(
|
||||
result1.wrap.pubKey,
|
||||
result2.wrap.pubKey,
|
||||
"Each gift wrap MUST use a fresh ephemeral key",
|
||||
)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 10. Inner Event Signature Verification
|
||||
// ========================================================================
|
||||
|
||||
@Test
|
||||
fun innerEventSignature_isVerifiable_afterUnwrap() =
|
||||
runTest {
|
||||
val result = factory.createCallOffer(sdpOffer, bob, callId, CallType.VIDEO, aliceSigner)
|
||||
val inner = result.wrap.unwrapThrowing(bobSigner)
|
||||
|
||||
// Verify that the inner event's signature is valid against alice's pubkey
|
||||
assertEquals(alice, inner.pubKey)
|
||||
assertTrue(inner.verify(), "Recipient must be able to verify sender's signature on inner event")
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 11. SDP Content with Special Characters Survives Round-Trip
|
||||
// ========================================================================
|
||||
|
||||
@Test
|
||||
fun sdpWithSpecialChars_survivesGiftWrapRoundTrip() =
|
||||
runTest {
|
||||
val complexSdp =
|
||||
"v=0\r\n" +
|
||||
"o=- 4611731400430051336 2 IN IP4 127.0.0.1\r\n" +
|
||||
"s=-\r\n" +
|
||||
"t=0 0\r\n" +
|
||||
"a=group:BUNDLE 0 1\r\n" +
|
||||
"a=msid-semantic: WMS stream0\r\n" +
|
||||
"m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n" +
|
||||
"c=IN IP4 0.0.0.0\r\n" +
|
||||
"a=rtcp:9 IN IP4 0.0.0.0\r\n" +
|
||||
"a=ice-ufrag:abc+def/ghi\r\n" +
|
||||
"a=ice-pwd:jkl=mno+pqr/stu\r\n" +
|
||||
"a=fingerprint:sha-256 AB:CD:EF:01:23:45:67:89\r\n"
|
||||
|
||||
val result = factory.createCallOffer(complexSdp, bob, callId, CallType.VOICE, aliceSigner)
|
||||
val inner = result.wrap.unwrapThrowing(bobSigner)
|
||||
assertIs<CallOfferEvent>(inner)
|
||||
assertEquals(complexSdp, inner.sdpOffer(), "Complex SDP with special chars must survive gift wrap round-trip")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun iceCandidateWithSpecialChars_survivesGiftWrapRoundTrip() =
|
||||
runTest {
|
||||
val candidateSdp = """candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx raddr 0.0.0.0 rport 0 generation 0 ufrag abc+def network-id 1"""
|
||||
val json = CallIceCandidateEvent.serializeCandidate(candidateSdp, "audio", 0)
|
||||
val result = factory.createIceCandidate(json, bob, callId, aliceSigner)
|
||||
|
||||
val inner = result.wrap.unwrapThrowing(bobSigner)
|
||||
assertIs<CallIceCandidateEvent>(inner)
|
||||
assertEquals(candidateSdp, inner.candidateSdp(), "ICE candidate SDP must survive JSON+NIP44 round-trip")
|
||||
assertEquals("audio", inner.sdpMid())
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 12. Group Offer with Context: per-peer SDP, all p-tags visible
|
||||
// ========================================================================
|
||||
|
||||
@Test
|
||||
fun groupOfferWithContext_perPeerSdp_allPTagsVisible() =
|
||||
runTest {
|
||||
// Caller creates per-peer offer using the group-context overload
|
||||
val bobResult =
|
||||
factory.createCallOffer(
|
||||
"bob-specific-sdp",
|
||||
bob,
|
||||
setOf(bob, carol),
|
||||
callId,
|
||||
CallType.VIDEO,
|
||||
aliceSigner,
|
||||
)
|
||||
|
||||
// Wrap is only for Bob
|
||||
assertEquals(bob, bobResult.wrap.recipientPubKey())
|
||||
|
||||
// Bob unwraps and sees all group members in p-tags
|
||||
val inner = bobResult.wrap.unwrapThrowing(bobSigner)
|
||||
assertIs<CallOfferEvent>(inner)
|
||||
assertEquals("bob-specific-sdp", inner.sdpOffer())
|
||||
assertEquals(setOf(bob, carol), inner.recipientPubKeys(), "Inner event must list all group members")
|
||||
assertTrue(inner.isGroupCall(), "Multiple p-tags should indicate a group call")
|
||||
assertTrue(inner.verify())
|
||||
|
||||
// Carol cannot decrypt Bob's wrap
|
||||
assertNull(bobResult.wrap.unwrapOrNull(carolSigner))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user