Merge pull request #2143 from vitorpamplona/claude/webrtc-nip-ac-tests-f1l1g
Add comprehensive test suite for NIP-AC call state machine
This commit is contained in:
@@ -15,17 +15,17 @@ NAT traversal — no custom server infrastructure is required.
|
||||
The protocol works as follows:
|
||||
|
||||
1. **Caller** creates a signed call offer event containing an SDP offer
|
||||
2. The event is wrapped in an **Ephemeral Gift Wrap** (kind `21059`) and published to relays
|
||||
2. The event is wrapped in an **ephemeral gift wrap** (kind `21059`) and published to relays
|
||||
3. **Callee** unwraps the event, verifies the signature, and decides whether to accept
|
||||
4. If accepted, callee sends back a gift-wrapped call answer event containing an SDP answer
|
||||
5. Both parties exchange **ICE candidates** as gift-wrapped events for NAT traversal
|
||||
6. A **direct WebRTC peer connection** is established for audio/video
|
||||
|
||||
All signaling events MUST be delivered using **Ephemeral Gift Wraps** (kind `21059`), an ephemeral
|
||||
variant of [NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md) Gift Wraps. Events are
|
||||
signed by the sender's key and wrapped directly (without the seal layer) — the ephemeral gift wrap's
|
||||
random ephemeral key already hides the sender from relay operators. Relays SHOULD treat kind `21059`
|
||||
events as ephemeral and not persist them to long-term storage.
|
||||
All signaling events MUST be delivered using **ephemeral gift wraps** (kind `21059`), an ephemeral
|
||||
variant of [NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md) gift wraps. Events are
|
||||
signed by the sender's key and wrapped directly (without the seal layer) — the gift wrap's random
|
||||
ephemeral key already hides the sender from relay operators. Relays SHOULD treat kind `21059` events
|
||||
as ephemeral and not persist them to long-term storage.
|
||||
|
||||
## Event Kinds
|
||||
|
||||
@@ -179,13 +179,13 @@ Used for mid-call changes such as toggling video on/off. The `content` field con
|
||||
|
||||
## Encryption and Delivery
|
||||
|
||||
All signaling events MUST be delivered using **Ephemeral Gift Wraps** (kind `21059`):
|
||||
All signaling events MUST be delivered using **ephemeral gift wraps** (kind `21059`):
|
||||
|
||||
1. **Sign** the signaling event with the sender's key
|
||||
2. **Wrap** the signed event directly using `EphemeralGiftWrapEvent` (kind `21059`) with NIP-44 encryption
|
||||
3. **Publish** the ephemeral gift wrap to the recipient's relay list
|
||||
2. **Wrap** the signed event in a kind `21059` event with NIP-44 encryption and a random ephemeral key
|
||||
3. **Publish** the gift wrap to the recipient's relay list
|
||||
|
||||
The seal layer (`SealedRumorEvent`) is NOT used. The ephemeral gift wrap already provides:
|
||||
The seal layer (kind `13`) is NOT used. The ephemeral gift wrap already provides:
|
||||
|
||||
- **NIP-44 encryption** — content is unreadable to relay operators
|
||||
- **Random ephemeral pubkey** — the relay cannot identify the sender
|
||||
@@ -194,7 +194,7 @@ The seal layer (`SealedRumorEvent`) is NOT used. The ephemeral gift wrap already
|
||||
|
||||
No `expiration` tag is needed on the inner signaling events or the outer wrap. The ephemeral kind itself communicates the transient nature of the data. Clients MUST still perform staleness checks (see Spam Prevention) to discard old events.
|
||||
|
||||
Recipients unwrap the ephemeral gift, verify the inner event's signature against the sender's pubkey, and then process the signaling message.
|
||||
Recipients decrypt the gift wrap using NIP-44 with their private key and the wrap's ephemeral pubkey, verify the inner event's signature against the sender's pubkey, and then process the signaling message.
|
||||
|
||||
## Protocol Flow
|
||||
|
||||
@@ -203,16 +203,16 @@ Recipients unwrap the ephemeral gift, verify the inner event's signature against
|
||||
```
|
||||
Caller Relay Callee
|
||||
| | |
|
||||
|-- EphemeralGiftWrap(CallOffer) ------->| |
|
||||
| |-- EphemeralGiftWrap(CallOffer) ------->|
|
||||
|-- GiftWrap(CallOffer) ------->| |
|
||||
| |-- GiftWrap(CallOffer) ------->|
|
||||
| | |
|
||||
| | [Callee unwraps, verifies signature]
|
||||
| | [Callee decrypts wrap, verifies signature]
|
||||
| | [Checks: is caller followed?]
|
||||
| | [YES → ring / NO → ignore]
|
||||
| | |
|
||||
|<-- EphemeralGiftWrap(CallAnswer) ------|<-- EphemeralGiftWrap(CallAnswer) ------|
|
||||
|<-- GiftWrap(CallAnswer) ------|<-- GiftWrap(CallAnswer) ------|
|
||||
| | |
|
||||
|<-> EphemeralGiftWrap(IceCandidate) <-->|<-> EphemeralGiftWrap(IceCandidate) <-->|
|
||||
|<-> GiftWrap(IceCandidate) <-->|<-> GiftWrap(IceCandidate) <-->|
|
||||
| | |
|
||||
|============= WebRTC P2P Connection Established ===============|
|
||||
| (relay no longer involved) |
|
||||
@@ -235,12 +235,12 @@ Either party may send a `CallRenegotiate` (kind 25055) during an active call to
|
||||
```
|
||||
Party A Relay Party B
|
||||
| | |
|
||||
|-- EphemeralGiftWrap(Renegotiate) ----->| |
|
||||
| |-- EphemeralGiftWrap(Renegotiate) ----->|
|
||||
|-- GiftWrap(Renegotiate) ----->| |
|
||||
| |-- GiftWrap(Renegotiate) ----->|
|
||||
| | |
|
||||
| | [Party B creates SDP answer] |
|
||||
| | |
|
||||
|<-- EphemeralGiftWrap(CallAnswer) ------|<-- EphemeralGiftWrap(CallAnswer) ------|
|
||||
|<-- GiftWrap(CallAnswer) ------|<-- GiftWrap(CallAnswer) ------|
|
||||
| | |
|
||||
|========= Updated WebRTC P2P Connection ========================|
|
||||
```
|
||||
@@ -436,7 +436,7 @@ This NIP does not mandate specific STUN or TURN servers. Clients SHOULD:
|
||||
### Event Lifecycle
|
||||
|
||||
- The `call-id` tag MUST be a UUID that is unique per call session. All signaling events for the same call share the same `call-id`.
|
||||
- Signaling data is ephemeral and has no long-term value. Using kind `21059` (Ephemeral Gift Wrap) signals to relays that these events are transient and SHOULD NOT be persisted.
|
||||
- Signaling data is ephemeral and has no long-term value. Using kind `21059` (ephemeral gift wrap) signals to relays that these events are transient and SHOULD NOT be persisted.
|
||||
- Clients SHOULD implement a ringing timeout (e.g., 60 seconds). If no answer is received, the call transitions to a "timed out" state.
|
||||
- After a call ends, the call state SHOULD auto-reset to idle after a brief display period (e.g., 2 seconds) to ensure the client is ready for subsequent calls.
|
||||
|
||||
@@ -478,6 +478,148 @@ These self-notification events use the same `call-id` as the original call and f
|
||||
- If SDP offer/answer creation fails, the client SHOULD surface the error instead of hanging silently.
|
||||
- Clients SHOULD handle `ICE_CONNECTION_FAILED` state by ending the call and notifying the user of a connection failure.
|
||||
|
||||
## Test Vectors
|
||||
|
||||
Implementations SHOULD validate their state machine and event handling against these test scenarios.
|
||||
|
||||
### Event Structure Tests
|
||||
|
||||
| # | Test | Verification |
|
||||
|---|------|-------------|
|
||||
| E1 | Build Call Offer (kind 25050) | Contains `p`, `call-id`, `call-type`, `alt` tags; content = SDP offer |
|
||||
| E2 | Build Call Answer (kind 25051) | Contains `p`, `call-id`, `alt` tags; content = SDP answer; NO `call-type` tag |
|
||||
| E3 | Build ICE Candidate (kind 25052) | Contains `p`, `call-id`, `alt` tags; content = JSON with `candidate`, `sdpMid`, `sdpMLineIndex` |
|
||||
| E4 | Build Call Hangup (kind 25053) | Contains `p`, `call-id`, `alt` tags; content MAY be empty or contain reason |
|
||||
| E5 | Build Call Reject (kind 25054) | Contains `p`, `call-id`, `alt` tags; content MAY be `"busy"` for auto-reject |
|
||||
| E6 | Build Call Renegotiate (kind 25055) | Contains `p`, `call-id`, `alt` tags; content = new SDP offer |
|
||||
| E7 | Group offer with N callees | N `p` tags present, one per callee |
|
||||
| E8 | ICE candidate in group call | Still addressed to single peer (1 `p` tag) |
|
||||
| E9 | All events for same call share `call-id` | Offer, answer, ICE, hangup, reject, renegotiate — same UUID |
|
||||
| E10 | Single callee offer is not a group call | Exactly 1 `p` tag |
|
||||
| E11 | P2P call flow event sequence | Offer → Answer → ICE → Hangup produce correct kinds in order |
|
||||
| E12 | Group answer includes all member `p` tags | All members visible in answer `p` tags |
|
||||
| E13 | Group hangup includes all member `p` tags | All members visible in hangup `p` tags |
|
||||
| E14 | Group reject includes all member `p` tags | All members visible in reject `p` tags |
|
||||
| E15 | Group renegotiate includes all member `p` tags | All members visible in renegotiate `p` tags |
|
||||
| E16 | Group members = `p` tags + event author | `groupMembers()` union includes the caller's pubkey |
|
||||
| E17 | ICE candidate JSON serialization round-trips | `serializeCandidate()` → parse → original values |
|
||||
| E18 | ICE candidate JSON escapes special characters | Quotes and backslashes in SDP survive serialization |
|
||||
|
||||
### State Machine Tests
|
||||
|
||||
| # | Scenario | Initial State | Event/Action | Expected State |
|
||||
|---|----------|---------------|-------------|----------------|
|
||||
| S1 | Receive offer from followed user | Idle | CallOffer | IncomingCall |
|
||||
| S2 | Receive offer from non-followed user | Idle | CallOffer | Idle (ignored) |
|
||||
| S3 | Accept incoming call | IncomingCall | acceptCall() | Connecting |
|
||||
| S4 | ICE peer connected | Connecting | onPeerConnected() | Connected |
|
||||
| S5 | Peer hangs up (P2P) | Connected | CallHangup | Ended(PEER_HANGUP) |
|
||||
| S6 | Ended auto-resets | Ended | ~2s timeout | Idle |
|
||||
| S7 | Initiate outbound call | Idle | initiateCall() | Offering |
|
||||
| S8 | Receive answer to offer | Offering | CallAnswer | Connecting |
|
||||
| S9 | Reject incoming call | IncomingCall | rejectCall() | Ended(REJECTED) |
|
||||
| S10 | Peer rejects our offer (P2P) | Offering | CallReject | Ended(PEER_REJECTED) |
|
||||
| S11 | Busy auto-reject | Connected | CallOffer (different call-id) | Connected + publish CallReject("busy") |
|
||||
| S12 | Stale event (>20s old) | Any | Any signaling event | No state change |
|
||||
| S13 | Duplicate event (same ID) | Any | Re-delivered event | No state change |
|
||||
| S14 | Self ICE candidate echo | Any active | CallIceCandidate from self | Ignored |
|
||||
| S15 | Self hangup echo | Connected | CallHangup from self | Ignored (stay Connected) |
|
||||
| S16 | Self answer in IncomingCall | IncomingCall | CallAnswer from self | Ended(ANSWERED_ELSEWHERE) |
|
||||
| S17 | Self answer in Offering | Offering | CallAnswer from self | Ignored (stay Offering) |
|
||||
| S18 | Hangup from Offering | Offering | hangup() | Ended(HANGUP) |
|
||||
| S19 | Hangup from Connecting | Connecting | hangup() | Ended(HANGUP) |
|
||||
| S20 | Hangup from Connected | Connected | hangup() | Ended(HANGUP) |
|
||||
| S21 | Hangup from Idle | Idle | hangup() | Idle (no-op) |
|
||||
| S22 | Fresh event (<20s old) | Idle | CallOffer | IncomingCall (processed normally) |
|
||||
| S23 | Answer with wrong call-id | Offering | CallAnswer (wrong id) | Offering (ignored) |
|
||||
| S24 | Hangup with wrong call-id | Connected | CallHangup (wrong id) | Connected (ignored) |
|
||||
| S25 | ICE candidates forwarded via callback | Connecting | CallIceCandidate | Forwarded to onIceCandidateReceived |
|
||||
| S26 | Peer left callback fires on hangup | Connected (group) | CallHangup from one peer | onPeerLeft callback invoked |
|
||||
| S27 | Reset returns to Idle | Any | reset() | Idle |
|
||||
| S28 | Video call type preserved through states | IncomingCall(VIDEO) | accept → connect | All states carry VIDEO type |
|
||||
| S29 | Caller cancels while ringing | IncomingCall | CallHangup from caller | Ended(PEER_HANGUP) |
|
||||
|
||||
### Renegotiation Tests
|
||||
|
||||
| # | Scenario | Verification |
|
||||
|---|----------|-------------|
|
||||
| R1 | Renegotiate in Connected state | Forwarded to callback |
|
||||
| R2 | Renegotiate in Connecting state | Forwarded to callback |
|
||||
| R3 | Renegotiate in Idle state | Ignored |
|
||||
| R4 | Renegotiate with wrong call-id | Ignored |
|
||||
| R5 | Renegotiation response | MUST be CallAnswer (kind 25051), NOT CallRenegotiate |
|
||||
| R6 | Glare tiebreaker | Higher pubkey wins; lower pubkey rolls back |
|
||||
| R7 | Renegotiation preserves call-id | Same `call-id` tag in renegotiate event |
|
||||
|
||||
### Group Call Tests
|
||||
|
||||
| # | Scenario | Verification |
|
||||
|---|----------|-------------|
|
||||
| G1 | Group offer detected | Multiple `p` tags → group call |
|
||||
| G2 | Peer rejects in group | Removed from group; call continues with remaining |
|
||||
| G3 | All peers reject | Call ends |
|
||||
| G4 | Partial disconnect | Close only that peer's connection; continue with remaining |
|
||||
| G5 | Last peer leaves | Call ends |
|
||||
| G6 | Discover peer while ringing | Buffer peer; trigger mesh setup after accepting |
|
||||
| G7 | Mid-call offer (callee-to-callee) | Forwarded via callback with peer pubkey and SDP |
|
||||
| G8 | Invite new peer | Offer with all existing members + new invitee in `p` tags |
|
||||
| G9 | Callee-to-callee glare | Lower pubkey initiates; higher waits |
|
||||
|
||||
### ICE Candidate Buffering Tests
|
||||
|
||||
| # | Scenario | Verification |
|
||||
|---|----------|-------------|
|
||||
| B1 | Candidate arrives before session exists | Buffered in global buffer (keyed by sender) |
|
||||
| B2 | Multiple candidates buffered globally | All preserved per peer |
|
||||
| B3 | Global buffer drains on session creation | Moved to per-session buffer, global cleared |
|
||||
| B4 | Global buffer drains and flushes after remote desc | Candidates reach PeerConnection via `addIceCandidate()` |
|
||||
| B5 | Candidate arrives after session, before remote desc | Buffered per-session |
|
||||
| B6 | Candidate arrives after session and remote desc | Added directly via `addIceCandidate()` |
|
||||
| B7 | Per-session buffer not cleared on creation | Only cleared on flush after `setRemoteDescription()` |
|
||||
| B8 | Candidates buffered while ringing are preserved | Not lost when accepting — drained into new session |
|
||||
| B9 | Global buffers are separate per peer | Alice's buffer independent of Carol's |
|
||||
| B10 | Registering one session doesn't drain other peer | Only the target peer's global buffer is drained |
|
||||
| B11 | Full P2P flow: ICE through all phases | Ringing → accept → pre-desc → flush → post-desc |
|
||||
| B12 | Group call mesh with ICE buffering | Global buffer per peer, drain on mesh session creation |
|
||||
|
||||
### Gift Wrap Round-Trip Tests
|
||||
|
||||
| # | Scenario | Verification |
|
||||
|---|----------|-------------|
|
||||
| W1 | Call Offer round-trips through gift wrap | Sign → wrap → unwrap → correct typed event, valid signature |
|
||||
| W2 | Third party cannot decrypt | Gift wrap addressed to Bob, Carol cannot unwrap |
|
||||
| W3 | Call Answer round-trips | SDP answer content preserved |
|
||||
| W4 | ICE Candidate round-trips | JSON with candidate/sdpMid/sdpMLineIndex preserved |
|
||||
| W5 | Hangup round-trips | Reason string preserved (including empty) |
|
||||
| W6 | Reject round-trips | Including `"busy"` reason |
|
||||
| W7 | Renegotiate round-trips | New SDP offer preserved |
|
||||
| W8 | Group offer: per-peer wraps | Each callee can decrypt only their own wrap |
|
||||
| W9 | Group answer: broadcast wraps | All members can decrypt; inner event identical |
|
||||
| W10 | Group hangup: sign once, wrap per recipient | All wraps contain same inner event ID |
|
||||
| W11 | Group reject: sign once, wrap per recipient | All wraps contain same inner event ID |
|
||||
| W12 | Full P2P flow through gift wraps | All 7 signaling steps round-trip successfully |
|
||||
| W13 | Wrap uses ephemeral key (not sender's) | Gift wrap pubkey differs from inner event pubkey |
|
||||
| W14 | Each wrap uses unique ephemeral key | Two wraps for same content have different pubkeys |
|
||||
| W15 | Inner event signature verifiable after unwrap | Recipient can verify sender's signature |
|
||||
| W16 | SDP with special characters survives | CRLF, slashes, equals signs in SDP preserved |
|
||||
| W17 | ICE candidate with special characters survives | JSON escaping + NIP-44 encryption preserves content |
|
||||
| W18 | Group offer with context: per-peer SDP | Per-peer SDP content but all `p` tags visible |
|
||||
|
||||
### Interface-Level Tests (Full Pipeline)
|
||||
|
||||
| # | Scenario | Verification |
|
||||
|---|----------|-------------|
|
||||
| I1 | Initiate call | Publishes 1 gift wrap (kind 21059) |
|
||||
| I2 | Accept call | Publishes gift wrap answer(s) |
|
||||
| I3 | Reject call | Publishes gift wrap reject(s) |
|
||||
| I4 | Hangup | Publishes gift wrap hangup(s) |
|
||||
| I5 | Send renegotiation | Publishes 1 gift wrap renegotiate |
|
||||
| I6 | Send renegotiation answer | Publishes 1 gift wrap answer |
|
||||
| I7 | Busy auto-reject | Publishes gift wrap reject while staying in current call |
|
||||
| I8 | Group per-peer offers | Publishes 1 gift wrap per peer |
|
||||
| I9 | Invite peer | Publishes 1 gift wrap; invitee added to pending set |
|
||||
| I10 | Full P2P flow | Offer → Answer → Renegotiate → Hangup, all via gift wraps |
|
||||
|
||||
## References
|
||||
|
||||
- [NIP-01: Basic Protocol](https://github.com/nostr-protocol/nips/blob/master/01.md) — Event structure
|
||||
|
||||
+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 callOfferRoundTripsThroughGiftWrap() =
|
||||
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 callOfferCannotBeDecryptedByThirdParty() =
|
||||
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 callAnswerRoundTripsThroughGiftWrap() =
|
||||
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 iceCandidateRoundTripsThroughGiftWrap() =
|
||||
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 hangupRoundTripsThroughGiftWrap() =
|
||||
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 hangupEmptyReasonRoundTrips() =
|
||||
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 rejectRoundTripsThroughGiftWrap() =
|
||||
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 busyRejectRoundTripsThroughGiftWrap() =
|
||||
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 renegotiateRoundTripsThroughGiftWrap() =
|
||||
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 groupCallOfferPerPeerWrapsEachDecryptableOnlyByTarget() =
|
||||
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 groupCallAnswerBroadcastWrapsEachDecryptableByTarget() =
|
||||
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 groupHangupAllMembersReceiveIdenticalInnerEvent() =
|
||||
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 groupRejectAllMembersReceiveIdenticalInnerEvent() =
|
||||
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 fullP2PFlowAllSignalingEventsRoundTrip() =
|
||||
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 wrapMetadataIsCorrect() =
|
||||
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 eachWrapUsesUniqueEphemeralKey() =
|
||||
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 innerEventSignatureIsVerifiableAfterUnwrap() =
|
||||
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 sdpWithSpecialCharsSurvivesGiftWrapRoundTrip() =
|
||||
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 iceCandidateWithSpecialCharsSurvivesGiftWrapRoundTrip() =
|
||||
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 groupOfferWithContextPerPeerSdpAllPTagsVisible() =
|
||||
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))
|
||||
}
|
||||
}
|
||||
+460
@@ -0,0 +1,460 @@
|
||||
/*
|
||||
* 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.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 com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* NIP-AC Protocol Compliance Test Vectors
|
||||
*
|
||||
* These tests verify that event structures conform to the NIP-AC specification
|
||||
* for WebRTC signaling over Nostr. They serve as reference test vectors that
|
||||
* any NIP-AC implementation can use to validate compliance.
|
||||
*
|
||||
* Test categories:
|
||||
* 1. Event structure validation (kinds, required tags, content)
|
||||
* 2. P2P call flow (offer → answer → ICE → hangup)
|
||||
* 3. Call rejection flow
|
||||
* 4. Mid-call renegotiation (voice ↔ video switch)
|
||||
* 5. Group call flows (multi-party mesh)
|
||||
* 6. Self-event filtering rules
|
||||
* 7. Staleness / spam prevention
|
||||
*/
|
||||
class NipACStateMachineTest {
|
||||
// ---- Test identities ----
|
||||
private val alice = "aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111"
|
||||
private val bob = "bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222"
|
||||
private val carol = "cccc3333cccc3333cccc3333cccc3333cccc3333cccc3333cccc3333cccc3333"
|
||||
private val dave = "dddd4444dddd4444dddd4444dddd4444dddd4444dddd4444dddd4444dddd4444"
|
||||
private val callId = "550e8400-e29b-41d4-a716-446655440000"
|
||||
private val sdpOffer = "v=0\r\no=- 4611731400430051336 2 IN IP4 127.0.0.1\r\n..."
|
||||
private val sdpAnswer = "v=0\r\no=- 4611731400430051337 2 IN IP4 127.0.0.1\r\n..."
|
||||
|
||||
// ========================================================================
|
||||
// 1. Event Structure Validation
|
||||
// ========================================================================
|
||||
|
||||
@Test
|
||||
fun callOfferMustIncludeAllRequiredTags() {
|
||||
val template = CallOfferEvent.build(sdpOffer, bob, callId, CallType.VIDEO)
|
||||
assertEquals(CallOfferEvent.KIND, template.kind)
|
||||
assertEquals(sdpOffer, template.content)
|
||||
|
||||
val pTag = template.tags.firstOrNull { it[0] == "p" }
|
||||
assertNotNull(pTag, "Call offer MUST include p tag")
|
||||
assertEquals(bob, pTag[1])
|
||||
|
||||
val callIdTag = template.tags.firstOrNull { it[0] == "call-id" }
|
||||
assertNotNull(callIdTag, "Call offer MUST include call-id tag")
|
||||
assertEquals(callId, callIdTag[1])
|
||||
|
||||
val callTypeTag = template.tags.firstOrNull { it[0] == "call-type" }
|
||||
assertNotNull(callTypeTag, "Call offer MUST include call-type tag")
|
||||
assertEquals("video", callTypeTag[1])
|
||||
|
||||
val altTag = template.tags.firstOrNull { it[0] == "alt" }
|
||||
assertNotNull(altTag, "Call offer MUST include alt tag (NIP-31)")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun callAnswerMustIncludeRequiredTags() {
|
||||
val template = CallAnswerEvent.build(sdpAnswer, alice, callId)
|
||||
assertEquals(CallAnswerEvent.KIND, template.kind)
|
||||
assertEquals(sdpAnswer, template.content)
|
||||
|
||||
assertNotNull(template.tags.firstOrNull { it[0] == "p" }, "Call answer MUST include p tag")
|
||||
assertNotNull(template.tags.firstOrNull { it[0] == "call-id" }, "Call answer MUST include call-id tag")
|
||||
assertNotNull(template.tags.firstOrNull { it[0] == "alt" }, "Call answer MUST include alt tag")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun callAnswerMustNotIncludeCallTypeTag() {
|
||||
val template = CallAnswerEvent.build(sdpAnswer, alice, callId)
|
||||
val callTypeTag = template.tags.firstOrNull { it[0] == "call-type" }
|
||||
assertNull(callTypeTag, "Call answer MUST NOT include call-type tag (only offers have it)")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun iceCandidateMustIncludeRequiredTags() {
|
||||
val candidateJson = """{"candidate":"candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx","sdpMid":"0","sdpMLineIndex":0}"""
|
||||
val template = CallIceCandidateEvent.build(candidateJson, bob, callId)
|
||||
assertEquals(CallIceCandidateEvent.KIND, template.kind)
|
||||
assertEquals(candidateJson, template.content)
|
||||
|
||||
assertNotNull(template.tags.firstOrNull { it[0] == "p" })
|
||||
assertNotNull(template.tags.firstOrNull { it[0] == "call-id" })
|
||||
assertNotNull(template.tags.firstOrNull { it[0] == "alt" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hangupMayHaveEmptyContent() {
|
||||
val template = CallHangupEvent.build(bob, callId)
|
||||
assertEquals("", template.content, "Hangup content MAY be empty")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hangupMayHaveReasonContent() {
|
||||
val template = CallHangupEvent.build(bob, callId, reason = "user ended call")
|
||||
assertEquals("user ended call", template.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectMayHaveBusyReason() {
|
||||
val template = CallRejectEvent.build(alice, callId, reason = "busy")
|
||||
assertEquals("busy", template.content, "Reject with 'busy' content for auto-reject while in call")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun renegotiateMustContainNewSdpOffer() {
|
||||
val newSdp = "v=0\r\no=- 4611731400430051338 3 IN IP4 127.0.0.1\r\n..."
|
||||
val template = CallRenegotiateEvent.build(newSdp, bob, callId)
|
||||
assertEquals(CallRenegotiateEvent.KIND, template.kind)
|
||||
assertEquals(newSdp, template.content, "Renegotiate content MUST be new SDP offer")
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 2. P2P Call Flow: Offer → Answer → ICE → Connected → Hangup
|
||||
// ========================================================================
|
||||
|
||||
@Test
|
||||
fun p2pCallFlowProducesCorrectEventSequence() {
|
||||
// Step 1: Alice offers to Bob (voice call)
|
||||
val offer = CallOfferEvent.build(sdpOffer, bob, callId, CallType.VOICE)
|
||||
assertEquals(25050, offer.kind)
|
||||
assertEquals(bob, offer.tags.first { it[0] == "p" }[1])
|
||||
assertEquals("voice", offer.tags.first { it[0] == "call-type" }[1])
|
||||
|
||||
// Step 2: Bob answers
|
||||
val answer = CallAnswerEvent.build(sdpAnswer, alice, callId)
|
||||
assertEquals(25051, answer.kind)
|
||||
assertEquals(alice, answer.tags.first { it[0] == "p" }[1])
|
||||
assertEquals(callId, answer.tags.first { it[0] == "call-id" }[1])
|
||||
|
||||
// Step 3: ICE candidates exchange
|
||||
val iceJson =
|
||||
CallIceCandidateEvent.serializeCandidate(
|
||||
"candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx",
|
||||
"0",
|
||||
0,
|
||||
)
|
||||
val ice = CallIceCandidateEvent.build(iceJson, bob, callId)
|
||||
assertEquals(25052, ice.kind)
|
||||
|
||||
// Step 4: Hangup
|
||||
val hangup = CallHangupEvent.build(bob, callId)
|
||||
assertEquals(25053, hangup.kind)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 3. Call Rejection Flow
|
||||
// ========================================================================
|
||||
|
||||
@Test
|
||||
fun rejectEventHasCorrectStructure() {
|
||||
val reject = CallRejectEvent.build(alice, callId)
|
||||
assertEquals(25054, reject.kind)
|
||||
assertEquals(alice, reject.tags.first { it[0] == "p" }[1])
|
||||
assertEquals(callId, reject.tags.first { it[0] == "call-id" }[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun busyRejectHasContentBusy() {
|
||||
val reject = CallRejectEvent.build(alice, callId, reason = "busy")
|
||||
assertEquals("busy", reject.content)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 4. Mid-Call Renegotiation (Voice ↔ Video Switch)
|
||||
// ========================================================================
|
||||
|
||||
@Test
|
||||
fun renegotiationFlowProducesOfferThenAnswer() {
|
||||
// Party A sends renegotiate with new SDP
|
||||
val newSdp = "v=0\r\no=- 4611731400430051338 3 IN IP4 127.0.0.1\r\n..."
|
||||
val renegotiate = CallRenegotiateEvent.build(newSdp, bob, callId)
|
||||
assertEquals(25055, renegotiate.kind)
|
||||
assertEquals(newSdp, renegotiate.content)
|
||||
|
||||
// Party B responds with a CallAnswer (kind 25051), NOT a renegotiate
|
||||
val renegAnswer = CallAnswerEvent.build("answer-for-renego", alice, callId)
|
||||
assertEquals(25051, renegAnswer.kind, "Renegotiation response MUST be CallAnswer (25051)")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun renegotiationPreservesCallId() {
|
||||
val renegotiate = CallRenegotiateEvent.build(sdpOffer, bob, callId)
|
||||
assertEquals(callId, renegotiate.tags.first { it[0] == "call-id" }[1])
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 5. Group Call Flows
|
||||
// ========================================================================
|
||||
|
||||
@Test
|
||||
fun groupCallOfferIncludesPTagForEveryMember() {
|
||||
val callees = setOf(bob, carol, dave)
|
||||
val template = CallOfferEvent.build(sdpOffer, callees, callId, CallType.VIDEO)
|
||||
|
||||
val pTagValues =
|
||||
template.tags
|
||||
.filter { it[0] == "p" }
|
||||
.map { it[1] }
|
||||
.toSet()
|
||||
assertEquals(callees, pTagValues, "Group offer MUST include p tag for every callee")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun groupCallOfferIsDetectedByMultiplePTags() {
|
||||
// Single callee → not a group call
|
||||
val singleOffer = CallOfferEvent.build(sdpOffer, bob, callId, CallType.VOICE)
|
||||
assertEquals(1, singleOffer.tags.count { it[0] == "p" })
|
||||
|
||||
// Multiple callees → group call
|
||||
val groupOffer = CallOfferEvent.build(sdpOffer, setOf(bob, carol), callId, CallType.VOICE)
|
||||
assertEquals(2, groupOffer.tags.count { it[0] == "p" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun groupCallAnswerIncludesAllMemberPTags() {
|
||||
val allMembers = setOf(alice, bob, carol)
|
||||
val template = CallAnswerEvent.build(sdpAnswer, allMembers, callId)
|
||||
|
||||
val pTagValues =
|
||||
template.tags
|
||||
.filter { it[0] == "p" }
|
||||
.map { it[1] }
|
||||
.toSet()
|
||||
assertEquals(allMembers, pTagValues, "Group answer MUST include p tag for every member")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun groupCallHangupIncludesAllMemberPTags() {
|
||||
val allMembers = setOf(alice, bob, carol)
|
||||
val template = CallHangupEvent.build(allMembers, callId)
|
||||
|
||||
val pTagValues =
|
||||
template.tags
|
||||
.filter { it[0] == "p" }
|
||||
.map { it[1] }
|
||||
.toSet()
|
||||
assertEquals(allMembers, pTagValues)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun groupCallRejectIncludesAllMemberPTags() {
|
||||
val allMembers = setOf(alice, bob, carol)
|
||||
val template = CallRejectEvent.build(allMembers, callId)
|
||||
|
||||
val pTagValues =
|
||||
template.tags
|
||||
.filter { it[0] == "p" }
|
||||
.map { it[1] }
|
||||
.toSet()
|
||||
assertEquals(allMembers, pTagValues)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun groupCallRenegotiateIncludesAllMemberPTags() {
|
||||
val allMembers = setOf(alice, bob, carol)
|
||||
val template = CallRenegotiateEvent.build(sdpOffer, allMembers, callId)
|
||||
|
||||
val pTagValues =
|
||||
template.tags
|
||||
.filter { it[0] == "p" }
|
||||
.map { it[1] }
|
||||
.toSet()
|
||||
assertEquals(allMembers, pTagValues)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun groupMembersIncludesEventAuthorPlusPTags() {
|
||||
// Build a signed-style offer event to test groupMembers()
|
||||
val template = CallOfferEvent.build(sdpOffer, setOf(bob, carol), callId, CallType.VIDEO)
|
||||
// Template doesn't have pubKey set, so test via the tag helper
|
||||
val pTags =
|
||||
template.tags
|
||||
.filter { it[0] == "p" }
|
||||
.map { it[1] }
|
||||
.toSet()
|
||||
assertEquals(setOf(bob, carol), pTags)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun iceCandidateInGroupCallIsAddressedToSinglePeer() {
|
||||
// Per spec: ICE candidates remain addressed to a single peer
|
||||
val ice =
|
||||
CallIceCandidateEvent.build(
|
||||
"""{"candidate":"c","sdpMid":"0","sdpMLineIndex":0}""",
|
||||
bob,
|
||||
callId,
|
||||
)
|
||||
val pTags = ice.tags.filter { it[0] == "p" }
|
||||
assertEquals(1, pTags.size, "ICE candidate MUST be addressed to single peer, even in group call")
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 6. ICE Candidate Serialization
|
||||
// ========================================================================
|
||||
|
||||
@Test
|
||||
fun iceCandidateSerializationRoundTrips() {
|
||||
val sdp = "candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx raddr 0.0.0.0 rport 0 generation 0"
|
||||
val mid = "0"
|
||||
val index = 0
|
||||
|
||||
val json = CallIceCandidateEvent.serializeCandidate(sdp, mid, index)
|
||||
val template = CallIceCandidateEvent.build(json, bob, callId)
|
||||
|
||||
// Construct event to test parsing
|
||||
val event = CallIceCandidateEvent("fakeid", alice, TimeUtils.now(), template.tags, json, "fakesig")
|
||||
assertEquals(sdp, event.candidateSdp())
|
||||
assertEquals(mid, event.sdpMid())
|
||||
assertEquals(index, event.sdpMLineIndex())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun iceCandidateSerializationEscapesQuotes() {
|
||||
// Verify that quotes are properly escaped in the JSON output
|
||||
val sdp = """candidate:1 1 udp 2122260223 192.168.1.1 44323 typ host"""
|
||||
val json = CallIceCandidateEvent.serializeCandidate(sdp, "audio", 0)
|
||||
assertTrue(json.startsWith("{"), "Must be valid JSON object")
|
||||
assertTrue(json.endsWith("}"), "Must be valid JSON object")
|
||||
assertTrue(json.contains(""""candidate":"""), "Must contain candidate key")
|
||||
assertTrue(json.contains(""""sdpMid":"audio""""), "Must contain sdpMid")
|
||||
assertTrue(json.contains(""""sdpMLineIndex":0"""), "Must contain sdpMLineIndex")
|
||||
// Roundtrip through event parsing
|
||||
val event = CallIceCandidateEvent("fakeid", alice, TimeUtils.now(), arrayOf(), json, "fakesig")
|
||||
assertEquals(sdp, event.candidateSdp())
|
||||
assertEquals("audio", event.sdpMid())
|
||||
assertEquals(0, event.sdpMLineIndex())
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 7. Staleness Check
|
||||
// ========================================================================
|
||||
|
||||
@Test
|
||||
fun eventsOlderThan20SecondsMustBeDiscarded() {
|
||||
// Per spec: "Clients MUST discard signaling events older than 20 seconds"
|
||||
val now = TimeUtils.now()
|
||||
val freshCreatedAt = now - 5 // 5 seconds ago — fresh
|
||||
val staleCreatedAt = now - 25 // 25 seconds ago — stale
|
||||
|
||||
assertTrue(now - freshCreatedAt <= 20, "Event 5s old should be fresh")
|
||||
assertTrue(now - staleCreatedAt > 20, "Event 25s old should be stale")
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 8. Call-ID Consistency
|
||||
// ========================================================================
|
||||
|
||||
@Test
|
||||
fun allSignalingEventsForSameCallShareCallId() {
|
||||
val offer = CallOfferEvent.build(sdpOffer, bob, callId, CallType.VOICE)
|
||||
val answer = CallAnswerEvent.build(sdpAnswer, alice, callId)
|
||||
val ice = CallIceCandidateEvent.build("{}", bob, callId)
|
||||
val hangup = CallHangupEvent.build(bob, callId)
|
||||
val reject = CallRejectEvent.build(alice, callId)
|
||||
val renego = CallRenegotiateEvent.build(sdpOffer, bob, callId)
|
||||
|
||||
val events = listOf(offer, answer, ice, hangup, reject, renego)
|
||||
for (event in events) {
|
||||
val parsedCallId = event.tags.first { it[0] == "call-id" }[1]
|
||||
assertEquals(callId, parsedCallId, "All events for same call MUST share call-id")
|
||||
}
|
||||
}
|
||||
|
||||
// Renegotiation glare and callee-to-callee mesh tiebreakers are tested in
|
||||
// PeerSessionManagerTest (commons/commonTest/) where the actual logic lives.
|
||||
|
||||
// ========================================================================
|
||||
// 9. Event Kind Constants
|
||||
// ========================================================================
|
||||
|
||||
@Test
|
||||
fun eventKindsMustMatchSpec() {
|
||||
assertEquals(25050, CallOfferEvent.KIND, "Call Offer kind")
|
||||
assertEquals(25051, CallAnswerEvent.KIND, "Call Answer kind")
|
||||
assertEquals(25052, CallIceCandidateEvent.KIND, "ICE Candidate kind")
|
||||
assertEquals(25053, CallHangupEvent.KIND, "Call Hangup kind")
|
||||
assertEquals(25054, CallRejectEvent.KIND, "Call Reject kind")
|
||||
assertEquals(25055, CallRenegotiateEvent.KIND, "Call Renegotiate kind")
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 11. Invite New Peer to Active Group Call
|
||||
// ========================================================================
|
||||
|
||||
@Test
|
||||
fun inviteNewPeerOfferIncludesAllExistingMembersPlusInvitee() {
|
||||
// When inviting dave into an active call with alice, bob, carol:
|
||||
val allMembers = setOf(alice, bob, carol, dave)
|
||||
val inviteOffer = CallOfferEvent.build(sdpOffer, allMembers, callId, CallType.VIDEO)
|
||||
|
||||
val pTags =
|
||||
inviteOffer.tags
|
||||
.filter { it[0] == "p" }
|
||||
.map { it[1] }
|
||||
.toSet()
|
||||
assertEquals(allMembers, pTags, "Invite offer MUST include all existing members plus new invitee")
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 12. Multi-Device Support: Self-Addressed Events
|
||||
// ========================================================================
|
||||
|
||||
@Test
|
||||
fun selfAddressedAnswerForMultiDeviceSupport() {
|
||||
// Per spec: callee should publish answer addressed to own pubkey
|
||||
// for "answered elsewhere" notification to other devices
|
||||
val selfAnswer = CallAnswerEvent.build(sdpAnswer, setOf(alice, bob), callId)
|
||||
val pTags =
|
||||
selfAnswer.tags
|
||||
.filter { it[0] == "p" }
|
||||
.map { it[1] }
|
||||
.toSet()
|
||||
// The callee (bob) includes self in p-tags for group broadcast
|
||||
assertTrue(bob in pTags || alice in pTags, "Self-addressed answer must include own pubkey in p-tags")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selfAddressedRejectForMultiDeviceSupport() {
|
||||
val selfReject = CallRejectEvent.build(setOf(alice, bob), callId)
|
||||
val pTags =
|
||||
selfReject.tags
|
||||
.filter { it[0] == "p" }
|
||||
.map { it[1] }
|
||||
.toSet()
|
||||
assertTrue(pTags.size >= 2, "Self-reject in group should address all members including self")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user