1package eu.siacs.conversations.xmpp.jingle;
2
3import com.google.common.base.MoreObjects;
4import com.google.common.base.Objects;
5import com.google.common.base.Preconditions;
6import com.google.common.base.Predicates;
7import com.google.common.base.Strings;
8import com.google.common.collect.Collections2;
9import com.google.common.collect.ImmutableList;
10import com.google.common.collect.ImmutableMap;
11import com.google.common.collect.ImmutableMultimap;
12import com.google.common.collect.ImmutableSet;
13import com.google.common.collect.Iterables;
14import com.google.common.collect.Maps;
15import com.google.common.collect.Sets;
16
17import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
18import eu.siacs.conversations.xmpp.jingle.stanzas.GenericDescription;
19import eu.siacs.conversations.xmpp.jingle.stanzas.GenericTransportInfo;
20import eu.siacs.conversations.xmpp.jingle.stanzas.Group;
21import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
22import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
23import eu.siacs.conversations.xmpp.jingle.stanzas.OmemoVerifiedIceUdpTransportInfo;
24import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
25
26import java.util.Collection;
27import java.util.HashMap;
28import java.util.LinkedHashMap;
29import java.util.List;
30import java.util.Map;
31import java.util.Set;
32
33import javax.annotation.Nonnull;
34
35public class RtpContentMap {
36
37 public final Group group;
38 public final Map<String, DescriptionTransport> contents;
39
40 public RtpContentMap(Group group, Map<String, DescriptionTransport> contents) {
41 this.group = group;
42 this.contents = contents;
43 }
44
45 public static RtpContentMap of(final JinglePacket jinglePacket) {
46 final Map<String, DescriptionTransport> contents =
47 DescriptionTransport.of(jinglePacket.getJingleContents());
48 if (isOmemoVerified(contents)) {
49 return new OmemoVerifiedRtpContentMap(jinglePacket.getGroup(), contents);
50 } else {
51 return new RtpContentMap(jinglePacket.getGroup(), contents);
52 }
53 }
54
55 private static boolean isOmemoVerified(Map<String, DescriptionTransport> contents) {
56 final Collection<DescriptionTransport> values = contents.values();
57 if (values.size() == 0) {
58 return false;
59 }
60 for (final DescriptionTransport descriptionTransport : values) {
61 if (descriptionTransport.transport instanceof OmemoVerifiedIceUdpTransportInfo) {
62 continue;
63 }
64 return false;
65 }
66 return true;
67 }
68
69 public static RtpContentMap of(
70 final SessionDescription sessionDescription, final boolean isInitiator) {
71 final ImmutableMap.Builder<String, DescriptionTransport> contentMapBuilder =
72 new ImmutableMap.Builder<>();
73 for (SessionDescription.Media media : sessionDescription.media) {
74 final String id = Iterables.getFirst(media.attributes.get("mid"), null);
75 Preconditions.checkNotNull(id, "media has no mid");
76 contentMapBuilder.put(
77 id, DescriptionTransport.of(sessionDescription, isInitiator, media));
78 }
79 final String groupAttribute =
80 Iterables.getFirst(sessionDescription.attributes.get("group"), null);
81 final Group group = groupAttribute == null ? null : Group.ofSdpString(groupAttribute);
82 return new RtpContentMap(group, contentMapBuilder.build());
83 }
84
85 public Set<Media> getMedia() {
86 return Sets.newHashSet(
87 Collections2.transform(
88 contents.values(),
89 input -> {
90 final RtpDescription rtpDescription =
91 input == null ? null : input.description;
92 return rtpDescription == null
93 ? Media.UNKNOWN
94 : input.description.getMedia();
95 }));
96 }
97
98 public Set<Content.Senders> getSenders() {
99 return ImmutableSet.copyOf(Collections2.transform(contents.values(), dt -> dt.senders));
100 }
101
102 public List<String> getNames() {
103 return ImmutableList.copyOf(contents.keySet());
104 }
105
106 void requireContentDescriptions() {
107 if (this.contents.size() == 0) {
108 throw new IllegalStateException("No contents available");
109 }
110 for (Map.Entry<String, DescriptionTransport> entry : this.contents.entrySet()) {
111 if (entry.getValue().description == null) {
112 throw new IllegalStateException(
113 String.format("%s is lacking content description", entry.getKey()));
114 }
115 }
116 }
117
118 void requireDTLSFingerprint() {
119 requireDTLSFingerprint(false);
120 }
121
122 void requireDTLSFingerprint(final boolean requireActPass) {
123 if (this.contents.size() == 0) {
124 throw new IllegalStateException("No contents available");
125 }
126 for (Map.Entry<String, DescriptionTransport> entry : this.contents.entrySet()) {
127 final IceUdpTransportInfo transport = entry.getValue().transport;
128 final IceUdpTransportInfo.Fingerprint fingerprint = transport.getFingerprint();
129 if (fingerprint == null
130 || Strings.isNullOrEmpty(fingerprint.getContent())
131 || Strings.isNullOrEmpty(fingerprint.getHash())) {
132 throw new SecurityException(
133 String.format(
134 "Use of DTLS-SRTP (XEP-0320) is required for content %s",
135 entry.getKey()));
136 }
137 final IceUdpTransportInfo.Setup setup = fingerprint.getSetup();
138 if (setup == null) {
139 throw new SecurityException(
140 String.format(
141 "Use of DTLS-SRTP (XEP-0320) is required for content %s but missing setup attribute",
142 entry.getKey()));
143 }
144 if (requireActPass && setup != IceUdpTransportInfo.Setup.ACTPASS) {
145 throw new SecurityException(
146 "Initiator needs to offer ACTPASS as setup for DTLS-SRTP (XEP-0320)");
147 }
148 }
149 }
150
151 JinglePacket toJinglePacket(final JinglePacket.Action action, final String sessionId) {
152 final JinglePacket jinglePacket = new JinglePacket(action, sessionId);
153 if (this.group != null) {
154 jinglePacket.addGroup(this.group);
155 }
156 for (Map.Entry<String, DescriptionTransport> entry : this.contents.entrySet()) {
157 final DescriptionTransport descriptionTransport = entry.getValue();
158 final Content content =
159 new Content(
160 Content.Creator.INITIATOR,
161 descriptionTransport.senders,
162 entry.getKey());
163 if (descriptionTransport.description != null) {
164 content.addChild(descriptionTransport.description);
165 }
166 content.addChild(descriptionTransport.transport);
167 jinglePacket.addJingleContent(content);
168 }
169 return jinglePacket;
170 }
171
172 RtpContentMap transportInfo(
173 final String contentName, final IceUdpTransportInfo.Candidate candidate) {
174 final RtpContentMap.DescriptionTransport descriptionTransport = contents.get(contentName);
175 final IceUdpTransportInfo transportInfo =
176 descriptionTransport == null ? null : descriptionTransport.transport;
177 if (transportInfo == null) {
178 throw new IllegalArgumentException(
179 "Unable to find transport info for content name " + contentName);
180 }
181 final IceUdpTransportInfo newTransportInfo = transportInfo.cloneWrapper();
182 newTransportInfo.addChild(candidate);
183 return new RtpContentMap(
184 null,
185 ImmutableMap.of(
186 contentName,
187 new DescriptionTransport(
188 descriptionTransport.senders, null, newTransportInfo)));
189 }
190
191 RtpContentMap transportInfo() {
192 return new RtpContentMap(
193 null,
194 Maps.transformValues(
195 contents,
196 dt ->
197 new DescriptionTransport(
198 dt.senders, null, dt.transport.cloneWrapper())));
199 }
200
201 RtpContentMap withCandidates(
202 ImmutableMultimap<String, IceUdpTransportInfo.Candidate> candidates) {
203 final ImmutableMap.Builder<String, DescriptionTransport> contentBuilder =
204 new ImmutableMap.Builder<>();
205 for (final Map.Entry<String, DescriptionTransport> entry : this.contents.entrySet()) {
206 final String name = entry.getKey();
207 final DescriptionTransport descriptionTransport = entry.getValue();
208 final var transport = descriptionTransport.transport;
209 contentBuilder.put(
210 name,
211 new DescriptionTransport(
212 descriptionTransport.senders,
213 descriptionTransport.description,
214 transport.withCandidates(candidates.get(name))));
215 }
216 return new RtpContentMap(group, contentBuilder.build());
217 }
218
219 public IceUdpTransportInfo.Credentials getDistinctCredentials() {
220 final Set<IceUdpTransportInfo.Credentials> allCredentials = getCredentials();
221 final IceUdpTransportInfo.Credentials credentials =
222 Iterables.getFirst(allCredentials, null);
223 if (allCredentials.size() == 1 && credentials != null) {
224 if (Strings.isNullOrEmpty(credentials.password)
225 || Strings.isNullOrEmpty(credentials.ufrag)) {
226 throw new IllegalStateException("Credentials are missing password or ufrag");
227 }
228 return credentials;
229 }
230 throw new IllegalStateException("Content map does not have distinct credentials");
231 }
232
233 private Set<String> getCombinedIceOptions() {
234 final Collection<List<String>> combinedIceOptions =
235 Collections2.transform(contents.values(), dt -> dt.transport.getIceOptions());
236 return ImmutableSet.copyOf(Iterables.concat(combinedIceOptions));
237 }
238
239 public Set<IceUdpTransportInfo.Credentials> getCredentials() {
240 final Set<IceUdpTransportInfo.Credentials> credentials =
241 ImmutableSet.copyOf(
242 Collections2.transform(
243 contents.values(), dt -> dt.transport.getCredentials()));
244 if (credentials.isEmpty()) {
245 throw new IllegalStateException("Content map does not have any credentials");
246 }
247 return credentials;
248 }
249
250 public IceUdpTransportInfo.Credentials getCredentials(final String contentName) {
251 final DescriptionTransport descriptionTransport = this.contents.get(contentName);
252 if (descriptionTransport == null) {
253 throw new IllegalArgumentException(
254 String.format(
255 "Unable to find transport info for content name %s", contentName));
256 }
257 return descriptionTransport.transport.getCredentials();
258 }
259
260 public IceUdpTransportInfo.Setup getDtlsSetup() {
261 final Set<IceUdpTransportInfo.Setup> setups =
262 ImmutableSet.copyOf(
263 Collections2.transform(
264 contents.values(), dt -> dt.transport.getFingerprint().getSetup()));
265 final IceUdpTransportInfo.Setup setup = Iterables.getFirst(setups, null);
266 if (setups.size() == 1 && setup != null) {
267 return setup;
268 }
269 throw new IllegalStateException("Content map doesn't have distinct DTLS setup");
270 }
271
272 private DTLS getDistinctDtls() {
273 final Set<DTLS> dtlsSet =
274 ImmutableSet.copyOf(
275 Collections2.transform(
276 contents.values(),
277 dt -> {
278 final IceUdpTransportInfo.Fingerprint fp =
279 dt.transport.getFingerprint();
280 return new DTLS(fp.getHash(), fp.getSetup(), fp.getContent());
281 }));
282 final DTLS dtls = Iterables.getFirst(dtlsSet, null);
283 if (dtlsSet.size() == 1 && dtls != null) {
284 return dtls;
285 }
286 throw new IllegalStateException("Content map doesn't have distinct DTLS setup");
287 }
288
289 public boolean emptyCandidates() {
290 int count = 0;
291 for (DescriptionTransport descriptionTransport : contents.values()) {
292 count += descriptionTransport.transport.getCandidates().size();
293 }
294 return count == 0;
295 }
296
297 public boolean hasFullTransportInfo() {
298 return Collections2.transform(this.contents.values(), dt -> dt.transport.isStub())
299 .contains(false);
300 }
301
302 public RtpContentMap modifiedCredentials(
303 IceUdpTransportInfo.Credentials credentials, final IceUdpTransportInfo.Setup setup) {
304 final ImmutableMap.Builder<String, DescriptionTransport> contentMapBuilder =
305 new ImmutableMap.Builder<>();
306 for (final Map.Entry<String, DescriptionTransport> content : contents.entrySet()) {
307 final DescriptionTransport descriptionTransport = content.getValue();
308 final RtpDescription rtpDescription = descriptionTransport.description;
309 final IceUdpTransportInfo transportInfo = descriptionTransport.transport;
310 final IceUdpTransportInfo modifiedTransportInfo =
311 transportInfo.modifyCredentials(credentials, setup);
312 contentMapBuilder.put(
313 content.getKey(),
314 new DescriptionTransport(
315 descriptionTransport.senders, rtpDescription, modifiedTransportInfo));
316 }
317 return new RtpContentMap(this.group, contentMapBuilder.build());
318 }
319
320 public RtpContentMap modifiedSenders(final Content.Senders senders) {
321 return new RtpContentMap(
322 this.group,
323 Maps.transformValues(
324 contents,
325 dt -> new DescriptionTransport(senders, dt.description, dt.transport)));
326 }
327
328 public RtpContentMap modifiedSendersChecked(
329 final boolean isInitiator, final Map<String, Content.Senders> modification) {
330 final ImmutableMap.Builder<String, DescriptionTransport> contentMapBuilder =
331 new ImmutableMap.Builder<>();
332 for (final Map.Entry<String, DescriptionTransport> content : contents.entrySet()) {
333 final String id = content.getKey();
334 final DescriptionTransport descriptionTransport = content.getValue();
335 final Content.Senders currentSenders = descriptionTransport.senders;
336 final Content.Senders targetSenders = modification.get(id);
337 if (targetSenders == null || currentSenders == targetSenders) {
338 contentMapBuilder.put(id, descriptionTransport);
339 } else {
340 checkSenderModification(isInitiator, currentSenders, targetSenders);
341 contentMapBuilder.put(
342 id,
343 new DescriptionTransport(
344 targetSenders,
345 descriptionTransport.description,
346 descriptionTransport.transport));
347 }
348 }
349 return new RtpContentMap(this.group, contentMapBuilder.build());
350 }
351
352 private static void checkSenderModification(
353 final boolean isInitiator,
354 final Content.Senders current,
355 final Content.Senders target) {
356 if (isInitiator) {
357 // we were both sending and now other party only wants to receive
358 if (current == Content.Senders.BOTH && target == Content.Senders.INITIATOR) {
359 return;
360 }
361 // only we were sending but now other party wants to send too
362 if (current == Content.Senders.INITIATOR && target == Content.Senders.BOTH) {
363 return;
364 }
365 } else {
366 // we were both sending and now other party only wants to receive
367 if (current == Content.Senders.BOTH && target == Content.Senders.RESPONDER) {
368 return;
369 }
370 // only we were sending but now other party wants to send too
371 if (current == Content.Senders.RESPONDER && target == Content.Senders.BOTH) {
372 return;
373 }
374 }
375 throw new IllegalArgumentException(
376 String.format("Unsupported senders modification %s -> %s", current, target));
377 }
378
379 public RtpContentMap toContentModification(final Collection<String> modifications) {
380 return new RtpContentMap(
381 this.group, Maps.filterKeys(contents, Predicates.in(modifications)));
382 }
383
384 public RtpContentMap toStub() {
385 return new RtpContentMap(
386 null,
387 Maps.transformValues(
388 this.contents,
389 dt ->
390 new DescriptionTransport(
391 dt.senders,
392 RtpDescription.stub(dt.description.getMedia()),
393 IceUdpTransportInfo.STUB)));
394 }
395
396 public RtpContentMap activeContents() {
397 return new RtpContentMap(
398 group, Maps.filterValues(this.contents, dt -> dt.senders != Content.Senders.NONE));
399 }
400
401 public Diff diff(final RtpContentMap rtpContentMap) {
402 final Set<String> existingContentIds = this.contents.keySet();
403 final Set<String> newContentIds = rtpContentMap.contents.keySet();
404 return new Diff(
405 ImmutableSet.copyOf(Sets.difference(newContentIds, existingContentIds)),
406 ImmutableSet.copyOf(Sets.difference(existingContentIds, newContentIds)));
407 }
408
409 public boolean iceRestart(final RtpContentMap rtpContentMap) {
410 try {
411 return !getDistinctCredentials().equals(rtpContentMap.getDistinctCredentials());
412 } catch (final IllegalStateException e) {
413 return false;
414 }
415 }
416
417 public RtpContentMap addContent(
418 final RtpContentMap modification, final IceUdpTransportInfo.Setup setupOverwrite) {
419 final Map<String, DescriptionTransport> combined = merge(contents, modification.contents);
420 final Map<String, DescriptionTransport> combinedFixedTransport =
421 Maps.transformValues(
422 combined,
423 dt -> {
424 final IceUdpTransportInfo iceUdpTransportInfo;
425 if (dt.transport.isStub()) {
426 final IceUdpTransportInfo.Credentials credentials =
427 getDistinctCredentials();
428 final Collection<String> iceOptions = getCombinedIceOptions();
429 final DTLS dtls = getDistinctDtls();
430 iceUdpTransportInfo =
431 IceUdpTransportInfo.of(
432 credentials,
433 iceOptions,
434 setupOverwrite,
435 dtls.hash,
436 dtls.fingerprint);
437 } else {
438 final IceUdpTransportInfo.Fingerprint fp =
439 dt.transport.getFingerprint();
440 final IceUdpTransportInfo.Setup setup = fp.getSetup();
441 iceUdpTransportInfo =
442 IceUdpTransportInfo.of(
443 dt.transport.getCredentials(),
444 dt.transport.getIceOptions(),
445 setup == IceUdpTransportInfo.Setup.ACTPASS
446 ? setupOverwrite
447 : setup,
448 fp.getHash(),
449 fp.getContent());
450 }
451 return new DescriptionTransport(
452 dt.senders, dt.description, iceUdpTransportInfo);
453 });
454 return new RtpContentMap(modification.group, ImmutableMap.copyOf(combinedFixedTransport));
455 }
456
457 private static Map<String, DescriptionTransport> merge(
458 final Map<String, DescriptionTransport> a, final Map<String, DescriptionTransport> b) {
459 final Map<String, DescriptionTransport> combined = new LinkedHashMap<>();
460 combined.putAll(a);
461 combined.putAll(b);
462 return ImmutableMap.copyOf(combined);
463 }
464
465 public static class DescriptionTransport {
466 public final Content.Senders senders;
467 public final RtpDescription description;
468 public final IceUdpTransportInfo transport;
469
470 public DescriptionTransport(
471 final Content.Senders senders,
472 final RtpDescription description,
473 final IceUdpTransportInfo transport) {
474 this.senders = senders;
475 this.description = description;
476 this.transport = transport;
477 }
478
479 public static DescriptionTransport of(final Content content) {
480 final GenericDescription description = content.getDescription();
481 final GenericTransportInfo transportInfo = content.getTransport();
482 final Content.Senders senders = content.getSenders();
483 final RtpDescription rtpDescription;
484 final IceUdpTransportInfo iceUdpTransportInfo;
485 if (description == null) {
486 rtpDescription = null;
487 } else if (description instanceof RtpDescription) {
488 rtpDescription = (RtpDescription) description;
489 } else {
490 throw new UnsupportedApplicationException(
491 "Content does not contain rtp description");
492 }
493 if (transportInfo instanceof IceUdpTransportInfo) {
494 iceUdpTransportInfo = (IceUdpTransportInfo) transportInfo;
495 } else {
496 throw new UnsupportedTransportException(
497 "Content does not contain ICE-UDP transport");
498 }
499 return new DescriptionTransport(
500 senders,
501 rtpDescription,
502 OmemoVerifiedIceUdpTransportInfo.upgrade(iceUdpTransportInfo));
503 }
504
505 private static DescriptionTransport of(
506 final SessionDescription sessionDescription,
507 final boolean isInitiator,
508 final SessionDescription.Media media) {
509 final Content.Senders senders = Content.Senders.of(media, isInitiator);
510 final RtpDescription rtpDescription = RtpDescription.of(sessionDescription, media);
511 final IceUdpTransportInfo transportInfo =
512 IceUdpTransportInfo.of(sessionDescription, media);
513 return new DescriptionTransport(senders, rtpDescription, transportInfo);
514 }
515
516 public static Map<String, DescriptionTransport> of(final Map<String, Content> contents) {
517 return ImmutableMap.copyOf(
518 Maps.transformValues(
519 contents, content -> content == null ? null : of(content)));
520 }
521 }
522
523 public static class UnsupportedApplicationException extends IllegalArgumentException {
524 UnsupportedApplicationException(String message) {
525 super(message);
526 }
527 }
528
529 public static class UnsupportedTransportException extends IllegalArgumentException {
530 UnsupportedTransportException(String message) {
531 super(message);
532 }
533 }
534
535 public static final class Diff {
536 public final Set<String> added;
537 public final Set<String> removed;
538
539 private Diff(final Set<String> added, final Set<String> removed) {
540 this.added = added;
541 this.removed = removed;
542 }
543
544 public boolean hasModifications() {
545 return !this.added.isEmpty() || !this.removed.isEmpty();
546 }
547
548 public boolean isEmpty() {
549 return this.added.isEmpty() && this.removed.isEmpty();
550 }
551
552 @Override
553 @Nonnull
554 public String toString() {
555 return MoreObjects.toStringHelper(this)
556 .add("added", added)
557 .add("removed", removed)
558 .toString();
559 }
560 }
561
562 public static final class DTLS {
563 public final String hash;
564 public final IceUdpTransportInfo.Setup setup;
565 public final String fingerprint;
566
567 private DTLS(String hash, IceUdpTransportInfo.Setup setup, String fingerprint) {
568 this.hash = hash;
569 this.setup = setup;
570 this.fingerprint = fingerprint;
571 }
572
573 @Override
574 public boolean equals(Object o) {
575 if (this == o) return true;
576 if (o == null || getClass() != o.getClass()) return false;
577 DTLS dtls = (DTLS) o;
578 return Objects.equal(hash, dtls.hash)
579 && setup == dtls.setup
580 && Objects.equal(fingerprint, dtls.fingerprint);
581 }
582
583 @Override
584 public int hashCode() {
585 return Objects.hashCode(hash, setup, fingerprint);
586 }
587 }
588}