001/**
002 *
003 * Copyright © 2009 Jonas Ådahl, 2011-2022 Florian Schmaus
004 *
005 * Licensed under the Apache License, Version 2.0 (the "License");
006 * you may not use this file except in compliance with the License.
007 * You may obtain a copy of the License at
008 *
009 *     http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.jivesoftware.smackx.caps;
018
019import java.nio.charset.StandardCharsets;
020import java.security.MessageDigest;
021import java.security.NoSuchAlgorithmException;
022import java.util.Collections;
023import java.util.Comparator;
024import java.util.HashMap;
025import java.util.HashSet;
026import java.util.LinkedList;
027import java.util.List;
028import java.util.Locale;
029import java.util.Map;
030import java.util.Queue;
031import java.util.Set;
032import java.util.SortedSet;
033import java.util.TreeSet;
034import java.util.WeakHashMap;
035import java.util.concurrent.ConcurrentLinkedQueue;
036
037import org.jivesoftware.smack.ConnectionCreationListener;
038import org.jivesoftware.smack.ConnectionListener;
039import org.jivesoftware.smack.Manager;
040import org.jivesoftware.smack.SmackConfiguration;
041import org.jivesoftware.smack.SmackException.NoResponseException;
042import org.jivesoftware.smack.SmackException.NotConnectedException;
043import org.jivesoftware.smack.StanzaListener;
044import org.jivesoftware.smack.XMPPConnection;
045import org.jivesoftware.smack.XMPPConnectionRegistry;
046import org.jivesoftware.smack.XMPPException.XMPPErrorException;
047import org.jivesoftware.smack.filter.AndFilter;
048import org.jivesoftware.smack.filter.PresenceTypeFilter;
049import org.jivesoftware.smack.filter.StanzaExtensionFilter;
050import org.jivesoftware.smack.filter.StanzaFilter;
051import org.jivesoftware.smack.filter.StanzaTypeFilter;
052import org.jivesoftware.smack.packet.Presence;
053import org.jivesoftware.smack.packet.PresenceBuilder;
054import org.jivesoftware.smack.packet.Stanza;
055import org.jivesoftware.smack.roster.AbstractPresenceEventListener;
056import org.jivesoftware.smack.roster.Roster;
057import org.jivesoftware.smack.util.StringUtils;
058import org.jivesoftware.smack.util.stringencoder.Base64;
059
060import org.jivesoftware.smackx.caps.cache.EntityCapsPersistentCache;
061import org.jivesoftware.smackx.caps.packet.CapsExtension;
062import org.jivesoftware.smackx.disco.AbstractNodeInformationProvider;
063import org.jivesoftware.smackx.disco.DiscoInfoLookupShortcutMechanism;
064import org.jivesoftware.smackx.disco.EntityCapabilitiesChangedListener;
065import org.jivesoftware.smackx.disco.ServiceDiscoveryManager;
066import org.jivesoftware.smackx.disco.packet.DiscoverInfo;
067import org.jivesoftware.smackx.disco.packet.DiscoverInfo.Feature;
068import org.jivesoftware.smackx.disco.packet.DiscoverInfo.Identity;
069import org.jivesoftware.smackx.disco.packet.DiscoverInfoBuilder;
070import org.jivesoftware.smackx.disco.packet.DiscoverInfoView;
071import org.jivesoftware.smackx.xdata.FormField;
072import org.jivesoftware.smackx.xdata.packet.DataForm;
073
074import org.jxmpp.jid.DomainBareJid;
075import org.jxmpp.jid.FullJid;
076import org.jxmpp.jid.Jid;
077import org.jxmpp.util.cache.LruCache;
078
079/**
080 * Manages own and others Entity Capabilities (XEP-0115).
081 * <p>
082 * Entity Capabilities is an XMPP extension which, in order to minimize network impact, caches the capabilities of
083 * remote XMPP entities. Those capabilities are determine with the help of the Service Discovery Protocol
084 * (<a href="https://xmpp.org/extensions/xep-0030.html">XEP-0030</a>, {@link ServiceDiscoveryManager}).
085 * </p>
086 *
087 * <h2>Usage</h2>
088 * <p>
089 * Entity Capabilities work silently in the background when enabled. If the remote XMPP entity does not support XEP-0115
090 * but XEP-0030 then XEP-0030 mechanisms are transparently used.
091 * </p>
092 * <p>
093 * The caches used by Smack for Entity Capabilities is non-persisent per default. However, it is is also possible to set
094 * a persistent Entity Capabilities cache, which is recommended.
095 * </p>
096 * <h2>Examples</h2>
097 *
098 * <h3>Enable Entity Capabilities</h3>
099 * <pre>{@code
100 * // Get an instance of entity caps manager for the specified connection
101 * EntityCapsManager mgr = EntityCapsManager.getInstanceFor(connection);
102 * // Enable entity capabilities
103 * mgr.enableEntityCaps();
104 * }</pre>
105 *
106 * <h3>Configure a persistent cache for Entity Capabilities</h3>
107 * <pre>{@code
108 * // Get an instance of entity caps manager for the specified connection
109 * EntityCapsManager mgr = EntityCapsManager.getInstanceFor(connection);
110 * // Create an cache, see smackx.entitycaps.cache for pre-defined cache implementations
111 * EntityCapsPersistentCache cache = new SimpleDirectoryPersistentCache(new File("/foo/cachedir"));
112 * // Set the cache
113 * mgr.setPersistentCache(cache);
114 * }</pre>
115 *
116 * @author Florian Schmaus
117 * @see <a href="http://www.xmpp.org/extensions/xep-0115.html">XEP-0115: Entity Capabilities</a>
118 */
119public final class EntityCapsManager extends Manager {
120
121    public static final String NAMESPACE = CapsExtension.NAMESPACE;
122    public static final String ELEMENT = CapsExtension.ELEMENT;
123
124    private static final Map<String, MessageDigest> SUPPORTED_HASHES = new HashMap<String, MessageDigest>();
125
126    /**
127     * The default hash. Currently 'sha-1'.
128     */
129    private static final String DEFAULT_HASH = StringUtils.SHA1;
130
131    private static String DEFAULT_ENTITY_NODE = SmackConfiguration.SMACK_URL_STRING;
132
133    static EntityCapsPersistentCache persistentCache;
134
135    private static boolean autoEnableEntityCaps = true;
136
137    private static final Map<XMPPConnection, EntityCapsManager> instances = new WeakHashMap<>();
138
139    private static final StanzaFilter PRESENCES_WITH_CAPS = new AndFilter(new StanzaTypeFilter(Presence.class), new StanzaExtensionFilter(
140                    ELEMENT, NAMESPACE));
141
142    /**
143     * Map of "node + '#' + hash" to DiscoverInfo data
144     */
145    static final LruCache<String, DiscoverInfo> CAPS_CACHE = new LruCache<>(1000);
146
147    /**
148     * Map of Full JID -&gt; DiscoverInfo/null. In case of c2s connection the
149     * key is formed as user@server/resource (resource is required) In case of
150     * link-local connection the key is formed as user@host (no resource) In
151     * case of a server or component the key is formed as domain
152     */
153    static final LruCache<Jid, NodeVerHash> JID_TO_NODEVER_CACHE = new LruCache<>(10000);
154
155    static {
156        XMPPConnectionRegistry.addConnectionCreationListener(new ConnectionCreationListener() {
157            @Override
158            public void connectionCreated(XMPPConnection connection) {
159                getInstanceFor(connection);
160            }
161        });
162
163        try {
164            MessageDigest sha1MessageDigest = MessageDigest.getInstance(DEFAULT_HASH);
165            SUPPORTED_HASHES.put(DEFAULT_HASH, sha1MessageDigest);
166        } catch (NoSuchAlgorithmException e) {
167            // Ignore
168        }
169
170        ServiceDiscoveryManager.addDiscoInfoLookupShortcutMechanism(new DiscoInfoLookupShortcutMechanism("XEP-0115: Entity Capabilities", 100) {
171            @Override
172            public DiscoverInfo getDiscoverInfoByUser(ServiceDiscoveryManager serviceDiscoveryManager, Jid jid) {
173                DiscoverInfo info = EntityCapsManager.getDiscoverInfoByUser(jid);
174                if (info != null) {
175                    return info;
176                }
177
178                NodeVerHash nodeVerHash = getNodeVerHashByJid(jid);
179                if (nodeVerHash == null) {
180                    return null;
181                }
182
183                try {
184                    info = serviceDiscoveryManager.discoverInfo(jid, nodeVerHash.getNodeVer());
185                } catch (NoResponseException | XMPPErrorException | NotConnectedException | InterruptedException e) {
186                    // TODO log
187                    return null;
188                }
189
190                if (verifyDiscoverInfoVersion(nodeVerHash.getVer(), nodeVerHash.getHash(), info)) {
191                    addDiscoverInfoByNode(nodeVerHash.getNodeVer(), info);
192                } else {
193                    // TODO log
194                }
195
196                return info;
197            }
198        });
199    }
200
201    /**
202     * Set the default entity node that will be used for new EntityCapsManagers.
203     *
204     * @param entityNode TODO javadoc me please
205     */
206    public static void setDefaultEntityNode(String entityNode) {
207        DEFAULT_ENTITY_NODE = entityNode;
208    }
209
210    /**
211     * Add DiscoverInfo to the database.
212     *
213     * @param nodeVer TODO javadoc me please
214     *            The node and verification String (e.g.
215     *            "http://psi-im.org#q07IKJEyjvHSyhy//CH0CxmKi8w=").
216     * @param info TODO javadoc me please
217     *            DiscoverInfo for the specified node.
218     */
219    static void addDiscoverInfoByNode(String nodeVer, DiscoverInfo info) {
220        CAPS_CACHE.put(nodeVer, info);
221
222        if (persistentCache != null)
223            persistentCache.addDiscoverInfoByNodePersistent(nodeVer, info);
224    }
225
226    /**
227     * Get the Node version (node#ver) of a JID. Returns a String or null if
228     * EntiyCapsManager does not have any information.
229     *
230     * @param jid TODO javadoc me please
231     *            the user (Full JID)
232     * @return the node version (node#ver) or null
233     */
234    public static String getNodeVersionByJid(Jid jid) {
235        NodeVerHash nvh = JID_TO_NODEVER_CACHE.lookup(jid);
236        if (nvh != null) {
237            return nvh.nodeVer;
238        } else {
239            return null;
240        }
241    }
242
243    public static NodeVerHash getNodeVerHashByJid(Jid jid) {
244        return JID_TO_NODEVER_CACHE.lookup(jid);
245    }
246
247    /**
248     * Get the discover info given a user name. The discover info is returned if
249     * the user has a node#ver associated with it and the node#ver has a
250     * discover info associated with it.
251     *
252     * @param user TODO javadoc me please
253     *            user name (Full JID)
254     * @return the discovered info
255     */
256    public static DiscoverInfo getDiscoverInfoByUser(Jid user) {
257        NodeVerHash nvh = JID_TO_NODEVER_CACHE.lookup(user);
258        if (nvh == null)
259            return null;
260
261        return getDiscoveryInfoByNodeVer(nvh.nodeVer);
262    }
263
264    /**
265     * Retrieve DiscoverInfo for a specific node.
266     *
267     * @param nodeVer TODO javadoc me please
268     *            The node name (e.g.
269     *            "http://psi-im.org#q07IKJEyjvHSyhy//CH0CxmKi8w=").
270     * @return The corresponding DiscoverInfo or null if none is known.
271     */
272    public static DiscoverInfo getDiscoveryInfoByNodeVer(String nodeVer) {
273        DiscoverInfo info = CAPS_CACHE.lookup(nodeVer);
274
275        // If it was not in CAPS_CACHE, try to retrieve the information from persistentCache
276        if (info == null && persistentCache != null) {
277            info = persistentCache.lookup(nodeVer);
278            // Promote the information to CAPS_CACHE if one was found
279            if (info != null) {
280                CAPS_CACHE.put(nodeVer, info);
281            }
282        }
283
284        // If we were able to retrieve information from one of the caches, copy it before returning
285        if (info != null)
286            info = new DiscoverInfo(info);
287
288        return info;
289    }
290
291    /**
292     * Set the persistent cache implementation.
293     *
294     * @param cache TODO javadoc me please
295     */
296    public static void setPersistentCache(EntityCapsPersistentCache cache) {
297        persistentCache = cache;
298    }
299
300    /**
301     * Sets the maximum cache sizes.
302     *
303     * @param maxJidToNodeVerSize TODO javadoc me please
304     * @param maxCapsCacheSize TODO javadoc me please
305     */
306    public static void setMaxsCacheSizes(int maxJidToNodeVerSize, int maxCapsCacheSize) {
307        JID_TO_NODEVER_CACHE.setMaxCacheSize(maxJidToNodeVerSize);
308        CAPS_CACHE.setMaxCacheSize(maxCapsCacheSize);
309    }
310
311    /**
312     * Clears the memory cache.
313     */
314    public static void clearMemoryCache() {
315        JID_TO_NODEVER_CACHE.clear();
316        CAPS_CACHE.clear();
317    }
318
319    private static void addCapsExtensionInfo(Jid from, CapsExtension capsExtension) {
320        String capsExtensionHash = capsExtension.getHash();
321        String hashInUppercase = capsExtensionHash.toUpperCase(Locale.US);
322        // SUPPORTED_HASHES uses the format of MessageDigest, which is uppercase, e.g. "SHA-1" instead of "sha-1"
323        if (!SUPPORTED_HASHES.containsKey(hashInUppercase))
324            return;
325        String hash = capsExtensionHash.toLowerCase(Locale.US);
326
327        String node = capsExtension.getNode();
328        String ver = capsExtension.getVer();
329
330        JID_TO_NODEVER_CACHE.put(from, new NodeVerHash(node, ver, hash));
331    }
332
333    private final Queue<CapsVersionAndHash> lastLocalCapsVersions = new ConcurrentLinkedQueue<>();
334
335    private final ServiceDiscoveryManager sdm;
336
337    private boolean entityCapsEnabled;
338    private CapsVersionAndHash currentCapsVersion;
339
340    /**
341     * The entity node String used by this EntityCapsManager instance.
342     */
343    private String entityNode = DEFAULT_ENTITY_NODE;
344
345    // Intercept presence packages and add caps data when intended.
346    // XEP-0115 specifies that a client SHOULD include entity capabilities
347    // with every presence notification it sends.
348    private void addCapsExtension(PresenceBuilder presenceBuilder) {
349        final CapsVersionAndHash capsVersionAndHash = getCapsVersionAndHash();
350        if (capsVersionAndHash == null) {
351            return;
352        }
353        CapsExtension caps = new CapsExtension(entityNode, capsVersionAndHash.version, capsVersionAndHash.hash);
354        presenceBuilder.overrideExtension(caps);
355    }
356
357    private EntityCapsManager(XMPPConnection connection) {
358        super(connection);
359        this.sdm = ServiceDiscoveryManager.getInstanceFor(connection);
360        instances.put(connection, this);
361
362        connection.addConnectionListener(new ConnectionListener() {
363            @Override
364            public void connected(XMPPConnection connection) {
365                // It's not clear when a server would report the caps stream
366                // feature, so we try to process it after we are connected and
367                // once after we are authenticated.
368                processCapsStreamFeatureIfAvailable(connection);
369            }
370            @Override
371            public void authenticated(XMPPConnection connection, boolean resumed) {
372                // It's not clear when a server would report the caps stream
373                // feature, so we try to process it after we are connected and
374                // once after we are authenticated.
375                processCapsStreamFeatureIfAvailable(connection);
376            }
377            private void processCapsStreamFeatureIfAvailable(XMPPConnection connection) {
378                CapsExtension capsExtension = connection.getFeature(
379                                CapsExtension.class);
380                if (capsExtension == null) {
381                    return;
382                }
383                DomainBareJid from = connection.getXMPPServiceDomain();
384                addCapsExtensionInfo(from, capsExtension);
385            }
386        });
387
388        if (autoEnableEntityCaps)
389            enableEntityCaps();
390
391        // Note that this is a *synchronous* stanza listener to avoid unnecessary feature lookups. If this were to be an
392        // asynchronous listener, then it would be possible that the entity caps information was not processed when the
393        // features of entity are looked up. See SMACK-937.
394        connection.addStanzaListener(new StanzaListener() {
395            // Listen for remote presence stanzas with the caps extension
396            // If we receive such a stanza, record the JID and nodeVer
397            @Override
398            public void processStanza(Stanza packet) {
399                if (!entityCapsEnabled())
400                    return;
401
402                CapsExtension capsExtension = CapsExtension.from(packet);
403                Jid from = packet.getFrom();
404                addCapsExtensionInfo(from, capsExtension);
405            }
406
407        }, PRESENCES_WITH_CAPS);
408
409        Roster.getInstanceFor(connection).addPresenceEventListener(new AbstractPresenceEventListener() {
410            @Override
411            public void presenceUnavailable(FullJid from, Presence presence) {
412                JID_TO_NODEVER_CACHE.remove(from);
413            }
414        });
415
416        // It's important to do this as last action. Since it changes the
417        // behavior of the SDM in some ways
418        sdm.addEntityCapabilitiesChangedListener(new EntityCapabilitiesChangedListener() {
419            @Override
420            public void onEntityCapabilitiesChanged(DiscoverInfo synthesizedDiscoveryInfo) {
421                if (!entityCapsEnabled()) {
422                    return;
423                }
424
425                updateLocalEntityCaps(synthesizedDiscoveryInfo);
426            }
427        });
428    }
429
430    public static synchronized EntityCapsManager getInstanceFor(XMPPConnection connection) {
431        if (SUPPORTED_HASHES.size() <= 0)
432            throw new IllegalStateException("No supported hashes for EntityCapsManager");
433
434        EntityCapsManager entityCapsManager = instances.get(connection);
435
436        if (entityCapsManager == null) {
437            entityCapsManager = new EntityCapsManager(connection);
438        }
439
440        return entityCapsManager;
441    }
442
443    public synchronized void enableEntityCaps() {
444        connection().addPresenceInterceptor(this::addCapsExtension, p -> {
445            return PresenceTypeFilter.AVAILABLE.accept(p);
446        });
447
448        // Add Entity Capabilities (XEP-0115) feature node.
449        sdm.addFeature(NAMESPACE);
450        entityCapsEnabled = true;
451    }
452
453    public synchronized void disableEntityCaps() {
454        entityCapsEnabled = false;
455        sdm.removeFeature(NAMESPACE);
456
457        connection().removePresenceInterceptor(this::addCapsExtension);
458    }
459
460    public boolean entityCapsEnabled() {
461        return entityCapsEnabled;
462    }
463
464    /**
465     * Remove a record telling what entity caps node a user has.
466     *
467     * @param user TODO javadoc me please
468     *            the user (Full JID)
469     */
470    public static void removeUserCapsNode(Jid user) {
471        // While JID_TO_NODEVER_CHACHE has the generic types <Jid, NodeVerHash>, it is ok to call remove with String
472        // arguments, since the same Jid and String representations would be equal and have the same hash code.
473        JID_TO_NODEVER_CACHE.remove(user);
474    }
475
476    /**
477     * Get our own caps version or {@code null} if none is yet set. The version depends on the enabled features.
478     * A caps version looks like '66/0NaeaBKkwk85efJTGmU47vXI='
479     *
480     * @return our own caps version or {@code null}.
481     */
482    public CapsVersionAndHash getCapsVersionAndHash() {
483        return currentCapsVersion;
484    }
485
486    /**
487     * Returns the local entity's NodeVer (e.g.
488     * "http://www.igniterealtime.org/projects/smack/#66/0NaeaBKkwk85efJTGmU47vXI=
489     * )
490     *
491     * @return the local NodeVer
492     */
493    public String getLocalNodeVer() {
494        CapsVersionAndHash capsVersionAndHash = getCapsVersionAndHash();
495        if (capsVersionAndHash == null) {
496            return null;
497        }
498        return entityNode + '#' + capsVersionAndHash.version;
499    }
500
501    /**
502     * Returns true if Entity Caps are supported by a given JID.
503     *
504     * @param jid TODO javadoc me please
505     * @return true if the entity supports Entity Capabilities.
506     * @throws XMPPErrorException if there was an XMPP error returned.
507     * @throws NoResponseException if there was no response from the remote entity.
508     * @throws NotConnectedException if the XMPP connection is not connected.
509     * @throws InterruptedException if the calling thread was interrupted.
510     */
511    public boolean areEntityCapsSupported(Jid jid) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
512        return sdm.supportsFeature(jid, NAMESPACE);
513    }
514
515    /**
516     * Returns true if Entity Caps are supported by the local service/server.
517     *
518     * @return true if the user's server supports Entity Capabilities.
519     * @throws XMPPErrorException if there was an XMPP error returned.
520     * @throws NoResponseException if there was no response from the remote entity.
521     * @throws NotConnectedException if the XMPP connection is not connected.
522     * @throws InterruptedException if the calling thread was interrupted.
523     */
524    public boolean areEntityCapsSupportedByServer() throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException  {
525        return areEntityCapsSupported(connection().getXMPPServiceDomain());
526    }
527
528    /**
529     * Updates the local user Entity Caps information with the data provided
530     *
531     * If we are connected and there was already a presence send, another
532     * presence is send to inform others about your new Entity Caps node string.
533     *
534     */
535    private void updateLocalEntityCaps(DiscoverInfo synthesizedDiscoveryInfo) {
536        XMPPConnection connection = connection();
537
538        DiscoverInfoBuilder discoverInfoBuilder = synthesizedDiscoveryInfo.asBuilder("synthesized-disco-info-result");
539        // getLocalNodeVer() will return a result only after currentCapsVersion is set. Therefore
540        // set it first and then call getLocalNodeVer()
541        currentCapsVersion = generateVerificationString(discoverInfoBuilder);
542        final String localNodeVer = getLocalNodeVer();
543        discoverInfoBuilder.setNode(localNodeVer);
544
545        final DiscoverInfo discoverInfo = discoverInfoBuilder.build();
546        addDiscoverInfoByNode(localNodeVer, discoverInfo);
547
548        if (lastLocalCapsVersions.size() > 10) {
549            CapsVersionAndHash oldCapsVersion = lastLocalCapsVersions.poll();
550            sdm.removeNodeInformationProvider(entityNode + '#' + oldCapsVersion.version);
551        }
552        lastLocalCapsVersions.add(currentCapsVersion);
553
554        if (connection != null)
555            JID_TO_NODEVER_CACHE.put(connection.getUser(), new NodeVerHash(entityNode, currentCapsVersion));
556
557        final List<Identity> identities = new LinkedList<>(ServiceDiscoveryManager.getInstanceFor(connection).getIdentities());
558        sdm.setNodeInformationProvider(localNodeVer, new AbstractNodeInformationProvider() {
559            List<String> features = sdm.getFeatures();
560            List<DataForm> packetExtensions = sdm.getExtendedInfo();
561            @Override
562            public List<String> getNodeFeatures() {
563                return features;
564            }
565            @Override
566            public List<Identity> getNodeIdentities() {
567                return identities;
568            }
569            @Override
570            public List<DataForm> getNodePacketExtensions() {
571                return packetExtensions;
572            }
573        });
574    }
575
576    /**
577     * Verify DiscoverInfo and Caps Node as defined in XEP-0115 5.4 Processing
578     * Method.
579     *
580     * @see <a href="http://xmpp.org/extensions/xep-0115.html#ver-proc">XEP-0115
581     *      5.4 Processing Method</a>
582     *
583     * @param ver TODO javadoc me please
584     * @param hash TODO javadoc me please
585     * @param info TODO javadoc me please
586     * @return true if it's valid and should be cache, false if not
587     */
588    public static boolean verifyDiscoverInfoVersion(String ver, String hash, DiscoverInfo info) {
589        // step 3.3 check for duplicate identities
590        if (info.containsDuplicateIdentities())
591            return false;
592
593        // step 3.4 check for duplicate features
594        if (info.containsDuplicateFeatures())
595            return false;
596
597        // step 3.5 check for well-formed packet extensions
598        if (!verifyPacketExtensions(info))
599            return false;
600
601        String calculatedVer = generateVerificationString(info, hash).version;
602
603        if (!ver.equals(calculatedVer))
604            return false;
605
606        return true;
607    }
608
609    /**
610     * Verify that the given discovery info is not ill-formed.
611     *
612     * @param info the discovery info to verify.
613     * @return true if the stanza extensions is not ill-formed
614     */
615    private static boolean verifyPacketExtensions(DiscoverInfo info) {
616        Set<String> foundFormTypes = new HashSet<>();
617        List<DataForm> dataForms = info.getExtensions(DataForm.class);
618        for (DataForm dataForm : dataForms) {
619            FormField formFieldTypeField = dataForm.getHiddenFormTypeField();
620            if (formFieldTypeField == null) {
621                continue;
622            }
623
624            String type = formFieldTypeField.getFirstValue();
625            boolean noDuplicate = foundFormTypes.add(type);
626            if (!noDuplicate) {
627                // Ill-formed extension: duplicate forms (by form field type string).
628                return false;
629            }
630        }
631
632        return true;
633    }
634
635    static CapsVersionAndHash generateVerificationString(DiscoverInfoView discoverInfo) {
636        return generateVerificationString(discoverInfo, null);
637    }
638
639    /**
640     * Generates a XEP-115 Verification String
641     *
642     * @see <a href="http://xmpp.org/extensions/xep-0115.html#ver">XEP-115
643     *      Verification String</a>
644     *
645     * @param discoverInfo TODO javadoc me please
646     * @param hash TODO javadoc me please
647     *            the used hash function, if null, default hash will be used
648     * @return The generated verification String or null if the hash is not
649     *         supported
650     */
651    static CapsVersionAndHash generateVerificationString(DiscoverInfoView discoverInfo, String hash) {
652        if (hash == null) {
653            hash = DEFAULT_HASH;
654        }
655        // SUPPORTED_HASHES uses the format of MessageDigest, which is uppercase, e.g. "SHA-1" instead of "sha-1"
656        MessageDigest md = SUPPORTED_HASHES.get(hash.toUpperCase(Locale.US));
657        if (md == null)
658            return null;
659        // Then transform the hash to lowercase, as this value will be put on the wire within the caps element's hash
660        // attribute. I'm not sure if the standard is case insensitive here, but let's assume that even it is, there could
661        // be "broken" implementation in the wild, so we *always* transform to lowercase.
662        hash = hash.toLowerCase(Locale.US);
663
664        // 1. Initialize an empty string S ('sb' in this method).
665        StringBuilder sb = new StringBuilder(); // Use StringBuilder as we don't
666                                                // need thread-safe StringBuffer
667
668        // 2. Sort the service discovery identities by category and then by
669        // type and then by xml:lang
670        // (if it exists), formatted as CATEGORY '/' [TYPE] '/' [LANG] '/'
671        // [NAME]. Note that each slash is included even if the LANG or
672        // NAME is not included (in accordance with XEP-0030, the category and
673        // type MUST be included.
674        SortedSet<DiscoverInfo.Identity> sortedIdentities = new TreeSet<>();
675
676        sortedIdentities.addAll(discoverInfo.getIdentities());
677
678        // 3. For each identity, append the 'category/type/lang/name' to S,
679        // followed by the '<' character.
680        for (DiscoverInfo.Identity identity : sortedIdentities) {
681            sb.append(identity.getCategory());
682            sb.append('/');
683            sb.append(identity.getType());
684            sb.append('/');
685            sb.append(identity.getLanguage() == null ? "" : identity.getLanguage());
686            sb.append('/');
687            sb.append(identity.getName() == null ? "" : identity.getName());
688            sb.append('<');
689        }
690
691        // 4. Sort the supported service discovery features.
692        SortedSet<String> features = new TreeSet<>();
693        for (Feature f : discoverInfo.getFeatures())
694            features.add(f.getVar());
695
696        // 5. For each feature, append the feature to S, followed by the '<'
697        // character
698        for (String f : features) {
699            sb.append(f);
700            sb.append('<');
701        }
702
703        List<DataForm> extendedInfos = discoverInfo.getExtensions(DataForm.class);
704        for (DataForm extendedInfo : extendedInfos) {
705            if (!extendedInfo.hasHiddenFormTypeField()) {
706                // Only use the data form for calculation is it has a hidden FORM_TYPE field.
707                // See XEP-0115 5.4 step 3.f
708                continue;
709            }
710
711            // 6. If the service discovery information response includes
712            // XEP-0128 data forms, sort the forms by the FORM_TYPE (i.e.,
713            // by the XML character data of the <value/> element).
714            SortedSet<FormField> fs = new TreeSet<>(new Comparator<FormField>() {
715                @Override
716                public int compare(FormField f1, FormField f2) {
717                    return f1.getFieldName().compareTo(f2.getFieldName());
718                }
719            });
720
721            for (FormField f : extendedInfo.getFields()) {
722                if (!f.getFieldName().equals("FORM_TYPE")) {
723                    fs.add(f);
724                }
725            }
726
727            // Add FORM_TYPE values
728            formFieldValuesToCaps(Collections.singletonList(extendedInfo.getFormType()), sb);
729
730            // 7. 3. For each field other than FORM_TYPE:
731            // 1. Append the value of the "var" attribute, followed by the
732            // '<' character.
733            // 2. Sort values by the XML character data of the <value/>
734            // element.
735            // 3. For each <value/> element, append the XML character data,
736            // followed by the '<' character.
737            for (FormField f : fs) {
738                sb.append(f.getFieldName());
739                sb.append('<');
740                formFieldValuesToCaps(f.getRawValueCharSequences(), sb);
741            }
742        }
743
744        // 8. Ensure that S is encoded according to the UTF-8 encoding (RFC
745        // 3269).
746        // 9. Compute the verification string by hashing S using the algorithm
747        // specified in the 'hash' attribute (e.g., SHA-1 as defined in RFC
748        // 3174).
749        // The hashed data MUST be generated with binary output and
750        // encoded using Base64 as specified in Section 4 of RFC 4648
751        // (note: the Base64 output MUST NOT include whitespace and MUST set
752        // padding bits to zero).
753        byte[] bytes = sb.toString().getBytes(StandardCharsets.UTF_8);
754        byte[] digest;
755        synchronized (md) {
756            digest = md.digest(bytes);
757        }
758        String version = Base64.encodeToString(digest);
759        return new CapsVersionAndHash(version, hash);
760    }
761
762    private static void formFieldValuesToCaps(List<? extends CharSequence> i, StringBuilder sb) {
763        SortedSet<CharSequence> fvs = new TreeSet<>();
764        fvs.addAll(i);
765        for (CharSequence fv : fvs) {
766            sb.append(fv);
767            sb.append('<');
768        }
769    }
770
771    public static class NodeVerHash {
772        private String node;
773        private String hash;
774        private String ver;
775        private String nodeVer;
776
777        NodeVerHash(String node, CapsVersionAndHash capsVersionAndHash) {
778            this(node, capsVersionAndHash.version, capsVersionAndHash.hash);
779        }
780
781        NodeVerHash(String node, String ver, String hash) {
782            this.node = node;
783            this.ver = ver;
784            this.hash = hash;
785            nodeVer = node + "#" + ver;
786        }
787
788        public String getNodeVer() {
789            return nodeVer;
790        }
791
792        public String getNode() {
793            return node;
794        }
795
796        public String getHash() {
797            return hash;
798        }
799
800        public String getVer() {
801            return ver;
802        }
803    }
804}