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