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 -> 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 connection.addAsyncStanzaListener(new StanzaListener() { 392 // Listen for remote presence stanzas with the caps extension 393 // If we receive such a stanza, record the JID and nodeVer 394 @Override 395 public void processStanza(Stanza packet) { 396 if (!entityCapsEnabled()) 397 return; 398 399 CapsExtension capsExtension = CapsExtension.from(packet); 400 Jid from = packet.getFrom(); 401 addCapsExtensionInfo(from, capsExtension); 402 } 403 404 }, PRESENCES_WITH_CAPS); 405 406 Roster.getInstanceFor(connection).addPresenceEventListener(new AbstractPresenceEventListener() { 407 @Override 408 public void presenceUnavailable(FullJid from, Presence presence) { 409 JID_TO_NODEVER_CACHE.remove(from); 410 } 411 }); 412 413 // It's important to do this as last action. Since it changes the 414 // behavior of the SDM in some ways 415 sdm.addEntityCapabilitiesChangedListener(new EntityCapabilitiesChangedListener() { 416 @Override 417 public void onEntityCapabilitiesChanged(DiscoverInfo synthesizedDiscoveryInfo) { 418 if (!entityCapsEnabled()) { 419 return; 420 } 421 422 updateLocalEntityCaps(synthesizedDiscoveryInfo); 423 } 424 }); 425 } 426 427 public static synchronized EntityCapsManager getInstanceFor(XMPPConnection connection) { 428 if (SUPPORTED_HASHES.size() <= 0) 429 throw new IllegalStateException("No supported hashes for EntityCapsManager"); 430 431 EntityCapsManager entityCapsManager = instances.get(connection); 432 433 if (entityCapsManager == null) { 434 entityCapsManager = new EntityCapsManager(connection); 435 } 436 437 return entityCapsManager; 438 } 439 440 public synchronized void enableEntityCaps() { 441 connection().addPresenceInterceptor(this::addCapsExtension, p -> { 442 return PresenceTypeFilter.AVAILABLE.accept(p); 443 }); 444 445 // Add Entity Capabilities (XEP-0115) feature node. 446 sdm.addFeature(NAMESPACE); 447 entityCapsEnabled = true; 448 } 449 450 public synchronized void disableEntityCaps() { 451 entityCapsEnabled = false; 452 sdm.removeFeature(NAMESPACE); 453 454 connection().removePresenceInterceptor(this::addCapsExtension); 455 } 456 457 public boolean entityCapsEnabled() { 458 return entityCapsEnabled; 459 } 460 461 /** 462 * Remove a record telling what entity caps node a user has. 463 * 464 * @param user TODO javadoc me please 465 * the user (Full JID) 466 */ 467 public static void removeUserCapsNode(Jid user) { 468 // While JID_TO_NODEVER_CHACHE has the generic types <Jid, NodeVerHash>, it is ok to call remove with String 469 // arguments, since the same Jid and String representations would be equal and have the same hash code. 470 JID_TO_NODEVER_CACHE.remove(user); 471 } 472 473 /** 474 * Get our own caps version or {@code null} if none is yet set. The version depends on the enabled features. 475 * A caps version looks like '66/0NaeaBKkwk85efJTGmU47vXI=' 476 * 477 * @return our own caps version or {@code null}. 478 */ 479 public CapsVersionAndHash getCapsVersionAndHash() { 480 return currentCapsVersion; 481 } 482 483 /** 484 * Returns the local entity's NodeVer (e.g. 485 * "http://www.igniterealtime.org/projects/smack/#66/0NaeaBKkwk85efJTGmU47vXI= 486 * ) 487 * 488 * @return the local NodeVer 489 */ 490 public String getLocalNodeVer() { 491 CapsVersionAndHash capsVersionAndHash = getCapsVersionAndHash(); 492 if (capsVersionAndHash == null) { 493 return null; 494 } 495 return entityNode + '#' + capsVersionAndHash.version; 496 } 497 498 /** 499 * Returns true if Entity Caps are supported by a given JID. 500 * 501 * @param jid TODO javadoc me please 502 * @return true if the entity supports Entity Capabilities. 503 * @throws XMPPErrorException if there was an XMPP error returned. 504 * @throws NoResponseException if there was no response from the remote entity. 505 * @throws NotConnectedException if the XMPP connection is not connected. 506 * @throws InterruptedException if the calling thread was interrupted. 507 */ 508 public boolean areEntityCapsSupported(Jid jid) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { 509 return sdm.supportsFeature(jid, NAMESPACE); 510 } 511 512 /** 513 * Returns true if Entity Caps are supported by the local service/server. 514 * 515 * @return true if the user's server supports Entity Capabilities. 516 * @throws XMPPErrorException if there was an XMPP error returned. 517 * @throws NoResponseException if there was no response from the remote entity. 518 * @throws NotConnectedException if the XMPP connection is not connected. 519 * @throws InterruptedException if the calling thread was interrupted. 520 */ 521 public boolean areEntityCapsSupportedByServer() throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { 522 return areEntityCapsSupported(connection().getXMPPServiceDomain()); 523 } 524 525 /** 526 * Updates the local user Entity Caps information with the data provided 527 * 528 * If we are connected and there was already a presence send, another 529 * presence is send to inform others about your new Entity Caps node string. 530 * 531 */ 532 private void updateLocalEntityCaps(DiscoverInfo synthesizedDiscoveryInfo) { 533 XMPPConnection connection = connection(); 534 535 DiscoverInfoBuilder discoverInfoBuilder = synthesizedDiscoveryInfo.asBuilder("synthesized-disco-info-result"); 536 // getLocalNodeVer() will return a result only after currentCapsVersion is set. Therefore 537 // set it first and then call getLocalNodeVer() 538 currentCapsVersion = generateVerificationString(discoverInfoBuilder); 539 final String localNodeVer = getLocalNodeVer(); 540 discoverInfoBuilder.setNode(localNodeVer); 541 542 final DiscoverInfo discoverInfo = discoverInfoBuilder.build(); 543 addDiscoverInfoByNode(localNodeVer, discoverInfo); 544 545 if (lastLocalCapsVersions.size() > 10) { 546 CapsVersionAndHash oldCapsVersion = lastLocalCapsVersions.poll(); 547 sdm.removeNodeInformationProvider(entityNode + '#' + oldCapsVersion.version); 548 } 549 lastLocalCapsVersions.add(currentCapsVersion); 550 551 if (connection != null) 552 JID_TO_NODEVER_CACHE.put(connection.getUser(), new NodeVerHash(entityNode, currentCapsVersion)); 553 554 final List<Identity> identities = new LinkedList<>(ServiceDiscoveryManager.getInstanceFor(connection).getIdentities()); 555 sdm.setNodeInformationProvider(localNodeVer, new AbstractNodeInformationProvider() { 556 List<String> features = sdm.getFeatures(); 557 List<DataForm> packetExtensions = sdm.getExtendedInfo(); 558 @Override 559 public List<String> getNodeFeatures() { 560 return features; 561 } 562 @Override 563 public List<Identity> getNodeIdentities() { 564 return identities; 565 } 566 @Override 567 public List<DataForm> getNodePacketExtensions() { 568 return packetExtensions; 569 } 570 }); 571 } 572 573 /** 574 * Verify DiscoverInfo and Caps Node as defined in XEP-0115 5.4 Processing 575 * Method. 576 * 577 * @see <a href="http://xmpp.org/extensions/xep-0115.html#ver-proc">XEP-0115 578 * 5.4 Processing Method</a> 579 * 580 * @param ver TODO javadoc me please 581 * @param hash TODO javadoc me please 582 * @param info TODO javadoc me please 583 * @return true if it's valid and should be cache, false if not 584 */ 585 public static boolean verifyDiscoverInfoVersion(String ver, String hash, DiscoverInfo info) { 586 // step 3.3 check for duplicate identities 587 if (info.containsDuplicateIdentities()) 588 return false; 589 590 // step 3.4 check for duplicate features 591 if (info.containsDuplicateFeatures()) 592 return false; 593 594 // step 3.5 check for well-formed packet extensions 595 if (!verifyPacketExtensions(info)) 596 return false; 597 598 String calculatedVer = generateVerificationString(info, hash).version; 599 600 if (!ver.equals(calculatedVer)) 601 return false; 602 603 return true; 604 } 605 606 /** 607 * Verify that the given discovery info is not ill-formed. 608 * 609 * @param info the discovery info to verify. 610 * @return true if the stanza extensions is not ill-formed 611 */ 612 private static boolean verifyPacketExtensions(DiscoverInfo info) { 613 Set<String> foundFormTypes = new HashSet<>(); 614 List<DataForm> dataForms = info.getExtensions(DataForm.class); 615 for (DataForm dataForm : dataForms) { 616 FormField formFieldTypeField = dataForm.getHiddenFormTypeField(); 617 if (formFieldTypeField == null) { 618 continue; 619 } 620 621 String type = formFieldTypeField.getFirstValue(); 622 boolean noDuplicate = foundFormTypes.add(type); 623 if (!noDuplicate) { 624 // Ill-formed extension: duplicate forms (by form field type string). 625 return false; 626 } 627 } 628 629 return true; 630 } 631 632 static CapsVersionAndHash generateVerificationString(DiscoverInfoView discoverInfo) { 633 return generateVerificationString(discoverInfo, null); 634 } 635 636 /** 637 * Generates a XEP-115 Verification String 638 * 639 * @see <a href="http://xmpp.org/extensions/xep-0115.html#ver">XEP-115 640 * Verification String</a> 641 * 642 * @param discoverInfo TODO javadoc me please 643 * @param hash TODO javadoc me please 644 * the used hash function, if null, default hash will be used 645 * @return The generated verification String or null if the hash is not 646 * supported 647 */ 648 static CapsVersionAndHash generateVerificationString(DiscoverInfoView discoverInfo, String hash) { 649 if (hash == null) { 650 hash = DEFAULT_HASH; 651 } 652 // SUPPORTED_HASHES uses the format of MessageDigest, which is uppercase, e.g. "SHA-1" instead of "sha-1" 653 MessageDigest md = SUPPORTED_HASHES.get(hash.toUpperCase(Locale.US)); 654 if (md == null) 655 return null; 656 // Then transform the hash to lowercase, as this value will be put on the wire within the caps element's hash 657 // attribute. I'm not sure if the standard is case insensitive here, but let's assume that even it is, there could 658 // be "broken" implementation in the wild, so we *always* transform to lowercase. 659 hash = hash.toLowerCase(Locale.US); 660 661 // 1. Initialize an empty string S ('sb' in this method). 662 StringBuilder sb = new StringBuilder(); // Use StringBuilder as we don't 663 // need thread-safe StringBuffer 664 665 // 2. Sort the service discovery identities by category and then by 666 // type and then by xml:lang 667 // (if it exists), formatted as CATEGORY '/' [TYPE] '/' [LANG] '/' 668 // [NAME]. Note that each slash is included even if the LANG or 669 // NAME is not included (in accordance with XEP-0030, the category and 670 // type MUST be included. 671 SortedSet<DiscoverInfo.Identity> sortedIdentities = new TreeSet<>(); 672 673 sortedIdentities.addAll(discoverInfo.getIdentities()); 674 675 // 3. For each identity, append the 'category/type/lang/name' to S, 676 // followed by the '<' character. 677 for (DiscoverInfo.Identity identity : sortedIdentities) { 678 sb.append(identity.getCategory()); 679 sb.append('/'); 680 sb.append(identity.getType()); 681 sb.append('/'); 682 sb.append(identity.getLanguage() == null ? "" : identity.getLanguage()); 683 sb.append('/'); 684 sb.append(identity.getName() == null ? "" : identity.getName()); 685 sb.append('<'); 686 } 687 688 // 4. Sort the supported service discovery features. 689 SortedSet<String> features = new TreeSet<>(); 690 for (Feature f : discoverInfo.getFeatures()) 691 features.add(f.getVar()); 692 693 // 5. For each feature, append the feature to S, followed by the '<' 694 // character 695 for (String f : features) { 696 sb.append(f); 697 sb.append('<'); 698 } 699 700 List<DataForm> extendedInfos = discoverInfo.getExtensions(DataForm.class); 701 for (DataForm extendedInfo : extendedInfos) { 702 if (!extendedInfo.hasHiddenFormTypeField()) { 703 // Only use the data form for calculation is it has a hidden FORM_TYPE field. 704 // See XEP-0115 5.4 step 3.f 705 continue; 706 } 707 708 // 6. If the service discovery information response includes 709 // XEP-0128 data forms, sort the forms by the FORM_TYPE (i.e., 710 // by the XML character data of the <value/> element). 711 SortedSet<FormField> fs = new TreeSet<>(new Comparator<FormField>() { 712 @Override 713 public int compare(FormField f1, FormField f2) { 714 return f1.getFieldName().compareTo(f2.getFieldName()); 715 } 716 }); 717 718 for (FormField f : extendedInfo.getFields()) { 719 if (!f.getFieldName().equals("FORM_TYPE")) { 720 fs.add(f); 721 } 722 } 723 724 // Add FORM_TYPE values 725 formFieldValuesToCaps(Collections.singletonList(extendedInfo.getFormType()), sb); 726 727 // 7. 3. For each field other than FORM_TYPE: 728 // 1. Append the value of the "var" attribute, followed by the 729 // '<' character. 730 // 2. Sort values by the XML character data of the <value/> 731 // element. 732 // 3. For each <value/> element, append the XML character data, 733 // followed by the '<' character. 734 for (FormField f : fs) { 735 sb.append(f.getFieldName()); 736 sb.append('<'); 737 formFieldValuesToCaps(f.getRawValueCharSequences(), sb); 738 } 739 } 740 741 // 8. Ensure that S is encoded according to the UTF-8 encoding (RFC 742 // 3269). 743 // 9. Compute the verification string by hashing S using the algorithm 744 // specified in the 'hash' attribute (e.g., SHA-1 as defined in RFC 745 // 3174). 746 // The hashed data MUST be generated with binary output and 747 // encoded using Base64 as specified in Section 4 of RFC 4648 748 // (note: the Base64 output MUST NOT include whitespace and MUST set 749 // padding bits to zero). 750 byte[] bytes = sb.toString().getBytes(StandardCharsets.UTF_8); 751 byte[] digest; 752 synchronized (md) { 753 digest = md.digest(bytes); 754 } 755 String version = Base64.encodeToString(digest); 756 return new CapsVersionAndHash(version, hash); 757 } 758 759 private static void formFieldValuesToCaps(List<? extends CharSequence> i, StringBuilder sb) { 760 SortedSet<CharSequence> fvs = new TreeSet<>(); 761 fvs.addAll(i); 762 for (CharSequence fv : fvs) { 763 sb.append(fv); 764 sb.append('<'); 765 } 766 } 767 768 public static class NodeVerHash { 769 private String node; 770 private String hash; 771 private String ver; 772 private String nodeVer; 773 774 NodeVerHash(String node, CapsVersionAndHash capsVersionAndHash) { 775 this(node, capsVersionAndHash.version, capsVersionAndHash.hash); 776 } 777 778 NodeVerHash(String node, String ver, String hash) { 779 this.node = node; 780 this.ver = ver; 781 this.hash = hash; 782 nodeVer = node + "#" + ver; 783 } 784 785 public String getNodeVer() { 786 return nodeVer; 787 } 788 789 public String getNode() { 790 return node; 791 } 792 793 public String getHash() { 794 return hash; 795 } 796 797 public String getVer() { 798 return ver; 799 } 800 } 801}