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