RtpContentMap.java

  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.ImmutableSet;
 12import com.google.common.collect.Iterables;
 13import com.google.common.collect.Maps;
 14import com.google.common.collect.Sets;
 15
 16import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
 17import eu.siacs.conversations.xmpp.jingle.stanzas.GenericDescription;
 18import eu.siacs.conversations.xmpp.jingle.stanzas.GenericTransportInfo;
 19import eu.siacs.conversations.xmpp.jingle.stanzas.Group;
 20import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
 21import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
 22import eu.siacs.conversations.xmpp.jingle.stanzas.OmemoVerifiedIceUdpTransportInfo;
 23import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
 24
 25import java.util.Collection;
 26import java.util.HashMap;
 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    public IceUdpTransportInfo.Credentials getDistinctCredentials() {
201        final Set<IceUdpTransportInfo.Credentials> allCredentials = getCredentials();
202        final IceUdpTransportInfo.Credentials credentials =
203                Iterables.getFirst(allCredentials, null);
204        if (allCredentials.size() == 1 && credentials != null) {
205            if (Strings.isNullOrEmpty(credentials.password)
206                    || Strings.isNullOrEmpty(credentials.ufrag)) {
207                throw new IllegalStateException("Credentials are missing password or ufrag");
208            }
209            return credentials;
210        }
211        throw new IllegalStateException("Content map does not have distinct credentials");
212    }
213
214    private Set<String> getCombinedIceOptions() {
215        final Collection<List<String>> combinedIceOptions =
216                Collections2.transform(contents.values(), dt -> dt.transport.getIceOptions());
217        return ImmutableSet.copyOf(Iterables.concat(combinedIceOptions));
218    }
219
220    public Set<IceUdpTransportInfo.Credentials> getCredentials() {
221        final Set<IceUdpTransportInfo.Credentials> credentials =
222                ImmutableSet.copyOf(
223                        Collections2.transform(
224                                contents.values(), dt -> dt.transport.getCredentials()));
225        if (credentials.isEmpty()) {
226            throw new IllegalStateException("Content map does not have any credentials");
227        }
228        return credentials;
229    }
230
231    public IceUdpTransportInfo.Credentials getCredentials(final String contentName) {
232        final DescriptionTransport descriptionTransport = this.contents.get(contentName);
233        if (descriptionTransport == null) {
234            throw new IllegalArgumentException(
235                    String.format(
236                            "Unable to find transport info for content name %s", contentName));
237        }
238        return descriptionTransport.transport.getCredentials();
239    }
240
241    public IceUdpTransportInfo.Setup getDtlsSetup() {
242        final Set<IceUdpTransportInfo.Setup> setups =
243                ImmutableSet.copyOf(
244                        Collections2.transform(
245                                contents.values(), dt -> dt.transport.getFingerprint().getSetup()));
246        final IceUdpTransportInfo.Setup setup = Iterables.getFirst(setups, null);
247        if (setups.size() == 1 && setup != null) {
248            return setup;
249        }
250        throw new IllegalStateException("Content map doesn't have distinct DTLS setup");
251    }
252
253    private DTLS getDistinctDtls() {
254        final Set<DTLS> dtlsSet =
255                ImmutableSet.copyOf(
256                        Collections2.transform(
257                                contents.values(),
258                                dt -> {
259                                    final IceUdpTransportInfo.Fingerprint fp =
260                                            dt.transport.getFingerprint();
261                                    return new DTLS(fp.getHash(), fp.getSetup(), fp.getContent());
262                                }));
263        final DTLS dtls = Iterables.getFirst(dtlsSet, null);
264        if (dtlsSet.size() == 1 && dtls != null) {
265            return dtls;
266        }
267        throw new IllegalStateException("Content map doesn't have distinct DTLS setup");
268    }
269
270    public boolean emptyCandidates() {
271        int count = 0;
272        for (DescriptionTransport descriptionTransport : contents.values()) {
273            count += descriptionTransport.transport.getCandidates().size();
274        }
275        return count == 0;
276    }
277
278    public RtpContentMap modifiedCredentials(
279            IceUdpTransportInfo.Credentials credentials, final IceUdpTransportInfo.Setup setup) {
280        final ImmutableMap.Builder<String, DescriptionTransport> contentMapBuilder =
281                new ImmutableMap.Builder<>();
282        for (final Map.Entry<String, DescriptionTransport> content : contents.entrySet()) {
283            final DescriptionTransport descriptionTransport = content.getValue();
284            final RtpDescription rtpDescription = descriptionTransport.description;
285            final IceUdpTransportInfo transportInfo = descriptionTransport.transport;
286            final IceUdpTransportInfo modifiedTransportInfo =
287                    transportInfo.modifyCredentials(credentials, setup);
288            contentMapBuilder.put(
289                    content.getKey(),
290                    new DescriptionTransport(
291                            descriptionTransport.senders, rtpDescription, modifiedTransportInfo));
292        }
293        return new RtpContentMap(this.group, contentMapBuilder.build());
294    }
295
296    public RtpContentMap modifiedSenders(final Content.Senders senders) {
297        return new RtpContentMap(
298                this.group,
299                Maps.transformValues(
300                        contents,
301                        dt -> new DescriptionTransport(senders, dt.description, dt.transport)));
302    }
303
304    public RtpContentMap modifiedSendersChecked(
305            final boolean isInitiator, final Map<String, Content.Senders> modification) {
306        final ImmutableMap.Builder<String, DescriptionTransport> contentMapBuilder =
307                new ImmutableMap.Builder<>();
308        for (final Map.Entry<String, DescriptionTransport> content : contents.entrySet()) {
309            final String id = content.getKey();
310            final DescriptionTransport descriptionTransport = content.getValue();
311            final Content.Senders currentSenders = descriptionTransport.senders;
312            final Content.Senders targetSenders = modification.get(id);
313            if (targetSenders == null || currentSenders == targetSenders) {
314                contentMapBuilder.put(id, descriptionTransport);
315            } else {
316                checkSenderModification(isInitiator, currentSenders, targetSenders);
317                contentMapBuilder.put(
318                        id,
319                        new DescriptionTransport(
320                                targetSenders,
321                                descriptionTransport.description,
322                                descriptionTransport.transport));
323            }
324        }
325        return new RtpContentMap(this.group, contentMapBuilder.build());
326    }
327
328    private static void checkSenderModification(
329            final boolean isInitiator,
330            final Content.Senders current,
331            final Content.Senders target) {
332        if (isInitiator) {
333            // we were both sending and now other party only wants to receive
334            if (current == Content.Senders.BOTH && target == Content.Senders.INITIATOR) {
335                return;
336            }
337            // only we were sending but now other party wants to send too
338            if (current == Content.Senders.INITIATOR && target == Content.Senders.BOTH) {
339                return;
340            }
341        } else {
342            // we were both sending and now other party only wants to receive
343            if (current == Content.Senders.BOTH && target == Content.Senders.RESPONDER) {
344                return;
345            }
346            // only we were sending but now other party wants to send too
347            if (current == Content.Senders.RESPONDER && target == Content.Senders.BOTH) {
348                return;
349            }
350        }
351        throw new IllegalArgumentException(
352                String.format("Unsupported senders modification %s -> %s", current, target));
353    }
354
355    public RtpContentMap toContentModification(final Collection<String> modifications) {
356        return new RtpContentMap(
357                this.group,
358                Maps.transformValues(
359                        Maps.filterKeys(contents, Predicates.in(modifications)),
360                        dt ->
361                                new DescriptionTransport(
362                                        dt.senders, dt.description, IceUdpTransportInfo.STUB)));
363    }
364
365    public RtpContentMap toStub() {
366        return new RtpContentMap(
367                null,
368                Maps.transformValues(
369                        this.contents,
370                        dt ->
371                                new DescriptionTransport(
372                                        dt.senders,
373                                        RtpDescription.stub(dt.description.getMedia()),
374                                        IceUdpTransportInfo.STUB)));
375    }
376
377    public RtpContentMap activeContents() {
378        return new RtpContentMap(
379                group, Maps.filterValues(this.contents, dt -> dt.senders != Content.Senders.NONE));
380    }
381
382    public Diff diff(final RtpContentMap rtpContentMap) {
383        final Set<String> existingContentIds = this.contents.keySet();
384        final Set<String> newContentIds = rtpContentMap.contents.keySet();
385        return new Diff(
386                ImmutableSet.copyOf(Sets.difference(newContentIds, existingContentIds)),
387                ImmutableSet.copyOf(Sets.difference(existingContentIds, newContentIds)));
388    }
389
390    public boolean iceRestart(final RtpContentMap rtpContentMap) {
391        try {
392            return !getDistinctCredentials().equals(rtpContentMap.getDistinctCredentials());
393        } catch (final IllegalStateException e) {
394            return false;
395        }
396    }
397
398    public RtpContentMap addContent(
399            final RtpContentMap modification, final IceUdpTransportInfo.Setup setup) {
400        final IceUdpTransportInfo.Credentials credentials = getDistinctCredentials();
401        final Collection<String> iceOptions = getCombinedIceOptions();
402        final DTLS dtls = getDistinctDtls();
403        final Map<String, DescriptionTransport> combined = merge(contents, modification.contents);
404        final Map<String, DescriptionTransport> combinedFixedTransport =
405                Maps.transformValues(
406                        combined,
407                        dt -> {
408                            final IceUdpTransportInfo iceUdpTransportInfo;
409                            if (dt.transport.emptyCredentials()) {
410                                iceUdpTransportInfo =
411                                        IceUdpTransportInfo.of(
412                                                credentials,
413                                                iceOptions,
414                                                setup,
415                                                dtls.hash,
416                                                dtls.fingerprint);
417                            } else {
418                                iceUdpTransportInfo =
419                                        IceUdpTransportInfo.of(
420                                                dt.transport.getCredentials(),
421                                                iceOptions,
422                                                setup,
423                                                dtls.hash,
424                                                dtls.fingerprint);
425                            }
426                            return new DescriptionTransport(
427                                    dt.senders, dt.description, iceUdpTransportInfo);
428                        });
429        return new RtpContentMap(modification.group, combinedFixedTransport);
430    }
431
432    private static Map<String, DescriptionTransport> merge(
433            final Map<String, DescriptionTransport> a, final Map<String, DescriptionTransport> b) {
434        final Map<String, DescriptionTransport> combined = new LinkedHashMap<>();
435        combined.putAll(a);
436        combined.putAll(b);
437        return ImmutableMap.copyOf(combined);
438    }
439
440    public static class DescriptionTransport {
441        public final Content.Senders senders;
442        public final RtpDescription description;
443        public final IceUdpTransportInfo transport;
444
445        public DescriptionTransport(
446                final Content.Senders senders,
447                final RtpDescription description,
448                final IceUdpTransportInfo transport) {
449            this.senders = senders;
450            this.description = description;
451            this.transport = transport;
452        }
453
454        public static DescriptionTransport of(final Content content) {
455            final GenericDescription description = content.getDescription();
456            final GenericTransportInfo transportInfo = content.getTransport();
457            final Content.Senders senders = content.getSenders();
458            final RtpDescription rtpDescription;
459            final IceUdpTransportInfo iceUdpTransportInfo;
460            if (description == null) {
461                rtpDescription = null;
462            } else if (description instanceof RtpDescription) {
463                rtpDescription = (RtpDescription) description;
464            } else {
465                throw new UnsupportedApplicationException(
466                        "Content does not contain rtp description");
467            }
468            if (transportInfo instanceof IceUdpTransportInfo) {
469                iceUdpTransportInfo = (IceUdpTransportInfo) transportInfo;
470            } else {
471                throw new UnsupportedTransportException(
472                        "Content does not contain ICE-UDP transport");
473            }
474            return new DescriptionTransport(
475                    senders,
476                    rtpDescription,
477                    OmemoVerifiedIceUdpTransportInfo.upgrade(iceUdpTransportInfo));
478        }
479
480        private static DescriptionTransport of(
481                final SessionDescription sessionDescription,
482                final boolean isInitiator,
483                final SessionDescription.Media media) {
484            final Content.Senders senders = Content.Senders.of(media, isInitiator);
485            final RtpDescription rtpDescription = RtpDescription.of(sessionDescription, media);
486            final IceUdpTransportInfo transportInfo =
487                    IceUdpTransportInfo.of(sessionDescription, media);
488            return new DescriptionTransport(senders, rtpDescription, transportInfo);
489        }
490
491        public static Map<String, DescriptionTransport> of(final Map<String, Content> contents) {
492            return ImmutableMap.copyOf(
493                    Maps.transformValues(
494                            contents, content -> content == null ? null : of(content)));
495        }
496    }
497
498    public static class UnsupportedApplicationException extends IllegalArgumentException {
499        UnsupportedApplicationException(String message) {
500            super(message);
501        }
502    }
503
504    public static class UnsupportedTransportException extends IllegalArgumentException {
505        UnsupportedTransportException(String message) {
506            super(message);
507        }
508    }
509
510    public static final class Diff {
511        public final Set<String> added;
512        public final Set<String> removed;
513
514        private Diff(final Set<String> added, final Set<String> removed) {
515            this.added = added;
516            this.removed = removed;
517        }
518
519        public boolean hasModifications() {
520            return !this.added.isEmpty() || !this.removed.isEmpty();
521        }
522
523        public boolean isEmpty() {
524            return this.added.isEmpty() && this.removed.isEmpty();
525        }
526
527        @Override
528        @Nonnull
529        public String toString() {
530            return MoreObjects.toStringHelper(this)
531                    .add("added", added)
532                    .add("removed", removed)
533                    .toString();
534        }
535    }
536
537    public static final class DTLS {
538        public final String hash;
539        public final IceUdpTransportInfo.Setup setup;
540        public final String fingerprint;
541
542        private DTLS(String hash, IceUdpTransportInfo.Setup setup, String fingerprint) {
543            this.hash = hash;
544            this.setup = setup;
545            this.fingerprint = fingerprint;
546        }
547
548        @Override
549        public boolean equals(Object o) {
550            if (this == o) return true;
551            if (o == null || getClass() != o.getClass()) return false;
552            DTLS dtls = (DTLS) o;
553            return Objects.equal(hash, dtls.hash)
554                    && setup == dtls.setup
555                    && Objects.equal(fingerprint, dtls.fingerprint);
556        }
557
558        @Override
559        public int hashCode() {
560            return Objects.hashCode(hash, setup, fingerprint);
561        }
562    }
563}