Skip to content

feat:added webrtc protocol - #119

Merged
TheTechmage merged 2 commits into
decentralized-identity:mainfrom
VeriDID:webrtc
Mar 16, 2026
Merged

feat:added webrtc protocol#119
TheTechmage merged 2 commits into
decentralized-identity:mainfrom
VeriDID:webrtc

Conversation

@vinaysingh8866

Copy link
Copy Markdown
Contributor

Add WebRTC 1.0 Protocol

Summary

This PR introduces the WebRTC 1.0 protocol that uses DIDComm v2 as the signaling channel for secure, privacy-preserving audio/video calls. It supports both peer-to-peer mesh (2–8 participants) and SFU‑mediated (10–100+) topologies in a single, cohesive spec. The protocol binds WebRTC DTLS certificate fingerprints to DID keys to prevent MITM, preserves E2EE in SFU mode via SFrame, and adds Out‑of‑Band (OOB) one‑click join links (QR/URL) with ephemeral prekeys for a smooth onboarding experience.

What's Added

Key Features

Topologies

  • Mesh (2–8 participants): DIDComm-only mediator support; messages propose, offer, answer, ice, renegotiate, update, end
  • SFU (10–100+): Requires mediator with sfu-mediator role; messages create-room, join-room, room-offer, room-answer, participant-joined, participant-left, quality-update, simulcast-control, leave-room, destroy-room

Signaling & Identity

  • DIDComm v2 authcrypt for all signaling
  • Fingerprint Binding: JWS binds WebRTC DTLS fingerprints to DID key (prevents MITM)
  • Discover Features 2.0 used to detect SFU capabilities

End-to-End Encryption

  • Mesh: DTLS-SRTP provides E2EE
  • SFU: SFrame for application-layer E2EE; sender keys are generated and distributed end-to-end by clients via DIDComm (announce/rotate/revoke). SFU forwards ciphertext and never sees plaintext media.

Out-of-Band Join Links (OOB)

  • didcomm://?_oob=... (preferred) and HTTPS fragment fallback
  • Short TTL, single-use tokens, audience and origin binding
  • Ephemeral prekeys for first-contact bootstrapping without a mediator; subsequent signaling is DID-authenticated

Privacy & Network

  • NAT traversal via ICE/STUN/TURN; optional relay-only policy
  • Pairwise DIDs, mDNS candidates, minimal metadata in headers

Protocol Messages (selection)

Mesh

  • propose, offer, answer, ice, renegotiate, update, reject, end

SFU

  • create-room, room-created, invite, join-room, room-offer, room-answer, participant-joined, participant-left, quality-update, simulcast-control, leave-room, destroy-room, ping/pong

State Machine

  • Connection/room lifecycle states covering IDLEPROPOSING/CREATING_ROOMOFFERING/ROOM_ACTIVEJOININGCONNECTINGCONNECTEDCLOSED, with glare handling and ICE restart support.

Use Cases

  • Small P2P calls with low latency and no SFU dependencies
  • Large multiparty rooms (10–100+) with SFU, retaining true E2EE via SFrame
  • One‑click guest joins via OOB links (QR/URL) sent over email/SMS/DM

Security Highlights

  • DIDComm authcrypt for signaling; pairwise DIDs by default
  • DID‑signed DTLS fingerprints with freshness and exact SDP match
  • E2EE in SFU via SFrame; SFU never unwraps keys nor sees plaintext
  • OOB links with short TTL, single-use (use_limit), audience/origin binding, and per‑link ephemeral prekeys

Interop & Composition

  • Composes with: Discover Features 2.0, Message Pickup 4.0, Coordinate Mediation 3.0, Report Problem 2.0, Vaults 1.0 (optional encrypted recording)
  • Optional DID Doc service endpoints for ICE servers and SFU capability hints

References

  • WebRTC 1.0 (W3C), RFC 8829 (JSEP), RFC 8445 (ICE)
  • SFrame (IETF draft), Insertable Streams
  • DIDComm v2, Discover Features 2.0, Message Pickup 4.0, Coordinate Mediation 3.0, Vaults 1.0

@TheTechmage

Copy link
Copy Markdown
Member

I was trying to create another implementation in my own app over the weekend, but I was struggling to understand the message/state flow. Would you be able to add a flow diagram? I may be wrong, but it seems to me like both propose and offer are sent at the same time from the caller. This didn't make much sense to me.

On a side note, is there a reason why we're using reject instead of problem-report? The reason in the example seems more like a "problem-report-like" reason.

@vinaysingh8866

Copy link
Copy Markdown
Contributor Author

@TheTechmage You are correct - Both propose and offer being sent simultaneously is confusing

The intended flow is:

Alice (Caller)                          Bob (Callee)
  │
  │ [Creates RTCPeerConnection]
  │ [Calls createOffer()]
  │
  │──── propose ────────────────────→   "I want video+audio call"
  │──── offer ──────────────────────→   "Here's my SDP offer"
  │     (sent immediately after)
  │                                     [Phone rings: "Alice calling..."]
  │                                     [User clicks Accept/Decline]
  │
  │ [Waits for response...]
  │
  │←─── answer ──────────────────────   ✅ Acceptance (SDP answer)
  │     OR
  │←─── reject ──────────────────────   ❌ Rejection (reason)

Why BOTH messages exist:

propose :

  • Purpose: Capability negotiation and UI context
  • Contains: Media types (audio, video), topology (mesh/sfu), ICE policy, TURN/STUN servers
  • For the callee's UI: Shows "Alice wants to call with video and audio"
  • For capability check: "Do I support VP9 codec?" / "Do I have a camera?"
  • Doesn't create WebRTC objects yet on the callee side

offer :

  • Purpose: Actual WebRTC SDP session description
  • Contains: Full SDP, DID-signed fingerprint proof for security, tie-breaker for glare
  • Security: Binds DTLS certificate to caller's DID (prevents MITM)
  • Sent immediately after propose (optimistic connection setup)

How the callee communicates acceptance:

There is NO separate "accept" message! The acceptance is communicated by:

  • answer message = "Yes, I accept" (contains Bob's SDP answer)
  • reject message = "No, I decline" (contains reason like "user-declined", "incompatible-capabilities")

State transitions:

Caller: IDLE → propose sent → PROPOSING → offer sent → OFFERING
Caller: OFFERING → answer received → CONNECTING ✅ (Accepted!)
Caller: OFFERING → reject received → CLOSED ❌ (Rejected!)

2. Should We Use reject or problem-report?

Use reject for:

  • ✅ User declined the call
  • ✅ User is busy/unavailable
  • ✅ Capabilities don't match (e.g., "I don't support the required codecs")
  • Pre-connection decision - "I choose not to do this"

Message structure :

{
  "type": "https://didcomm.org/webrtc/1.0/reject",
  "thid": "<thread-id>",
  "body": {
    "reason": "user-declined" | "incompatible-capabilities" | "busy"
  }
}

Use problem-report for:

  • ✅ ICE gathering timeout
  • ✅ TURN authentication failed
  • ✅ Fingerprint verification failed (security error)
  • ✅ Glare resolution timeout
  • Runtime errors during connection attempt - "Something went wrong"

Error codes mentioned in spec :

- incompatible-capabilities (codec negotiation failed during SDP)
- ice-gathering-timeout (ICE failed to gather candidates)
- turn-auth-failed (TURN credentials invalid)
- unverified-fingerprint (DID signature verification failed)
- glare-timeout (simultaneous offer resolution failed)
- policy-required (peer demands relay-only but can't provide)
- topology-unsupported
- room-full (SFU mode)
- sfu-unavailable

The Distinction:

Scenario Use Reason
User clicks "Decline" reject User choice, not an error
Codec mismatch detected in propose reject Proactive incompatibility check
ICE candidates fail to connect problem-report Runtime connection error
DTLS fingerprint doesn't match DID signature problem-report Security verification failure
TURN server returns 401 Unauthorized problem-report Infrastructure error

3. How Acceptance is Communicated

The Complete Flow with Acceptance Signal

Alice (Caller)                                    Bob (Callee)
     │                                                 │
     │  STATE: IDLE                                    │  STATE: IDLE
     │                                                 │
     │  [1. Alice creates RTCPeerConnection]           │
     │  [2. Alice calls createOffer()]                 │
     │  [3. Alice signs fingerprint proof]             │
     │                                                 │
     │─────── propose ──────────────────────────────→  │
     │        { media: ["audio","video"], ... }        │
     │                                                 │
     │  STATE: PROPOSING                               │  [Bob's phone rings]
     │                                                 │  [UI shows: "Alice calling..."]
     │                                                 │  [Accept] [Decline] buttons
     │                                                 │
     │─────── offer ────────────────────────────────→  │
     │        { sdp: "...", fingerprint_proof: ... }   │
     │                                                 │
     │  STATE: OFFERING                                │
     │                                                 │
     │  [Alice waits for either answer or reject...]   │
     │                                                 │
     │                                                 │  === USER DECISION ===
     │                                                 │
     │  ┌─────────────────────────────────────────────┤
     │  │ IF USER CLICKS "ACCEPT":                    │
     │  │                                             │
     │  │                                             │  [Bob verifies fingerprint_proof]
     │  │                                             │  [Bob creates RTCPeerConnection]
     │  │                                             │  [Bob setRemoteDescription(Alice's offer)]
     │  │                                             │  [Bob creates answer]
     │  │                                             │  [Bob signs his fingerprint]
     │  │                                             │
     │←│─────── answer ────────────────────────────   │  ✅ ACCEPTANCE!
     │  │        { sdp: "...", fingerprint_proof }    │
     │  │                                             │
     │  │  STATE: CONNECTING                          │  STATE: CONNECTING
     │  │                                             │
     │  │  [Alice: "Got answer = Bob accepted!"]      │
     │  │  [Alice setRemoteDescription(Bob's answer)] │
     │  │                                             │
     │  │←─────── ice ──────────────────────────────→ │
     │  │                                             │
     │  │  STATE: CONNECTED                           │  STATE: CONNECTED
     │  │  [Media flows!]                             │
     │  └─────────────────────────────────────────────┤
     │                                                 │
     │  ┌─────────────────────────────────────────────┤
     │  │ IF USER CLICKS "DECLINE":                   │
     │  │                                             │
     │←│─────── reject ────────────────────────────   │  ❌ REJECTION!
     │  │        { reason: "user-declined" }          │
     │  │                                             │
     │  │  STATE: CLOSED                              │  STATE: CLOSED
     │  │  [Alice: "Call rejected by Bob"]            │
     │  │  [Alice closes RTCPeerConnection]           │
     │  └─────────────────────────────────────────────┘

Key Points:

  1. Alice sends propose AND offer back-to-back (optimistic connection setup)

    • Alice creates the PeerConnection BEFORE getting acceptance
    • This reduces latency for successful calls
  2. Bob receives both messages while the UI is showing "Incoming call..."

  3. Bob's response determines acceptance:

    • answer message = "Yes, I accept" (implicit acceptance)
    • reject message = "No, I decline" (explicit rejection)
  4. Alice knows Bob accepted when she receives answer

    • State transition: OFFERINGanswer receivedCONNECTING
  5. If Bob rejects, Alice gets reject instead

    • State transition: *reject receivedCLOSED

Why This Design?

This is standard WebRTC pattern (called "offer-answer model" from RFC 8829 JSEP):

  1. Reduces Latency: Alice can start ICE candidate gathering immediately https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/icecandidate_event
  2. Simpler State Machine: Only 2 possible responses (answer or reject)
  3. WebRTC-Native: Maps directly to createOffer()createAnswer() flow
Alice Sends Bob's Options What Alice Receives Meaning
propose + offer User accepts answer (with SDP) ✅ "Yes, let's connect!"
propose + offer User declines reject (with reason) ❌ "No, I can't/won't"
propose + offer Timeout (nothing) ⏱️ "Bob is offline/busy"

@vinaysingh8866

Copy link
Copy Markdown
Contributor Author

@TheTechmage we can move accept before propose but it would then increase the latency on accept

@TheTechmage

Copy link
Copy Markdown
Member

Just a thought I had, since propose and offer are generally sent at the same time, could both of those protocols be combined into the same one?

I retract my question. The use case for having them separate is related to SFU connections. So a user sends a proposal for an SFU-based chat, then an SFU invitation is forwarded in place of an offer. Is that correct?

Would love to see the flow charts and the full DIDComm messages added to the document!

@vinaysingh8866

Copy link
Copy Markdown
Contributor Author

@TheTechmage will update the flow chart in the readme and make it more readable. I'll also have a credo-ts package with all the messages implemented it'll be clear then.

@vinaysingh8866

Copy link
Copy Markdown
Contributor Author

@TheTechmage the implementation is here now
https://github.com/openwallet-foundation-labs/apostille

@TheTechmage
TheTechmage merged commit 2c44595 into decentralized-identity:main Mar 16, 2026
@TheTechmage

Copy link
Copy Markdown
Member

As discussed today, this has been in review for way too long in my opinion and it's in the proposed state. We're merging this and any updates can be handled in future PRs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants