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 // 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}