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