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