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