/** * Copyright 2009 Jonas Ã…dahl. * Copyright 2011-2013 Florian Schmaus * * All rights reserved. Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.jivesoftware.smackx.entitycaps; import org.jivesoftware.smack.Connection; import org.jivesoftware.smack.ConnectionCreationListener; import org.jivesoftware.smack.ConnectionListener; import org.jivesoftware.smack.PacketInterceptor; import org.jivesoftware.smack.PacketListener; import org.jivesoftware.smack.SmackConfiguration; import org.jivesoftware.smack.XMPPConnection; import org.jivesoftware.smack.XMPPException; import org.jivesoftware.smack.packet.IQ; import org.jivesoftware.smack.packet.Packet; import org.jivesoftware.smack.packet.PacketExtension; import org.jivesoftware.smack.packet.Presence; import org.jivesoftware.smack.filter.NotFilter; import org.jivesoftware.smack.filter.PacketFilter; import org.jivesoftware.smack.filter.AndFilter; import org.jivesoftware.smack.filter.PacketTypeFilter; import org.jivesoftware.smack.filter.PacketExtensionFilter; import org.jivesoftware.smack.util.Base64; import org.jivesoftware.smack.util.Cache; import org.jivesoftware.smackx.Form; import org.jivesoftware.smackx.FormField; import org.jivesoftware.smackx.NodeInformationProvider; import org.jivesoftware.smackx.ServiceDiscoveryManager; import org.jivesoftware.smackx.entitycaps.cache.EntityCapsPersistentCache; import org.jivesoftware.smackx.entitycaps.packet.CapsExtension; import org.jivesoftware.smackx.packet.DiscoverInfo; import org.jivesoftware.smackx.packet.DataForm; import org.jivesoftware.smackx.packet.DiscoverInfo.Feature; import org.jivesoftware.smackx.packet.DiscoverInfo.Identity; import org.jivesoftware.smackx.packet.DiscoverItems.Item; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.Iterator; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Queue; import java.util.SortedSet; import java.util.TreeSet; import java.util.WeakHashMap; import java.util.concurrent.ConcurrentLinkedQueue; import java.io.IOException; import java.lang.ref.WeakReference; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; /** * Keeps track of entity capabilities. * * @author Florian Schmaus */ public class EntityCapsManager { public static final String NAMESPACE = "http://jabber.org/protocol/caps"; public static final String ELEMENT = "c"; private static final String ENTITY_NODE = "http://www.igniterealtime.org/projects/smack"; private static final Map<String, MessageDigest> SUPPORTED_HASHES = new HashMap<String, MessageDigest>(); protected static EntityCapsPersistentCache persistentCache; private static Map<Connection, EntityCapsManager> instances = Collections .synchronizedMap(new WeakHashMap<Connection, EntityCapsManager>()); /** * Map of (node + '#" + hash algorithm) to DiscoverInfo data */ protected static Map<String, DiscoverInfo> caps = new Cache<String, DiscoverInfo>(1000, -1); /** * Map of Full JID -> DiscoverInfo/null. In case of c2s connection the * key is formed as user@server/resource (resource is required) In case of * link-local connection the key is formed as user@host (no resource) In * case of a server or component the key is formed as domain */ protected static Map<String, NodeVerHash> jidCaps = new Cache<String, NodeVerHash>(10000, -1); static { Connection.addConnectionCreationListener(new ConnectionCreationListener() { public void connectionCreated(Connection connection) { if (connection instanceof XMPPConnection) new EntityCapsManager(connection); } }); try { MessageDigest sha1MessageDigest = MessageDigest.getInstance("SHA-1"); SUPPORTED_HASHES.put("sha-1", sha1MessageDigest); } catch (NoSuchAlgorithmException e) { // Ignore } } private WeakReference<Connection> weakRefConnection; private ServiceDiscoveryManager sdm; private boolean entityCapsEnabled; private String currentCapsVersion; private boolean presenceSend = false; private Queue<String> lastLocalCapsVersions = new ConcurrentLinkedQueue<String>(); /** * Add DiscoverInfo to the database. * * @param nodeVer * The node and verification String (e.g. * "http://psi-im.org#q07IKJEyjvHSyhy//CH0CxmKi8w="). * @param info * DiscoverInfo for the specified node. */ public static void addDiscoverInfoByNode(String nodeVer, DiscoverInfo info) { caps.put(nodeVer, info); if (persistentCache != null) persistentCache.addDiscoverInfoByNodePersistent(nodeVer, info); } /** * Get the Node version (node#ver) of a JID. Returns a String or null if * EntiyCapsManager does not have any information. * * @param user * the user (Full JID) * @return the node version (node#ver) or null */ public static String getNodeVersionByJid(String jid) { NodeVerHash nvh = jidCaps.get(jid); if (nvh != null) { return nvh.nodeVer; } else { return null; } } public static NodeVerHash getNodeVerHashByJid(String jid) { return jidCaps.get(jid); } /** * Get the discover info given a user name. The discover info is returned if * the user has a node#ver associated with it and the node#ver has a * discover info associated with it. * * @param user * user name (Full JID) * @return the discovered info */ public static DiscoverInfo getDiscoverInfoByUser(String user) { NodeVerHash nvh = jidCaps.get(user); if (nvh == null) return null; return getDiscoveryInfoByNodeVer(nvh.nodeVer); } /** * Retrieve DiscoverInfo for a specific node. * * @param nodeVer * The node name (e.g. * "http://psi-im.org#q07IKJEyjvHSyhy//CH0CxmKi8w="). * @return The corresponding DiscoverInfo or null if none is known. */ public static DiscoverInfo getDiscoveryInfoByNodeVer(String nodeVer) { DiscoverInfo info = caps.get(nodeVer); if (info != null) info = new DiscoverInfo(info); return info; } /** * Set the persistent cache implementation * * @param cache * @throws IOException */ public static void setPersistentCache(EntityCapsPersistentCache cache) throws IOException { if (persistentCache != null) throw new IllegalStateException("Entity Caps Persistent Cache was already set"); persistentCache = cache; persistentCache.replay(); } /** * Sets the maximum Cache size for the JID to nodeVer Cache * * @param maxCacheSize */ @SuppressWarnings("rawtypes") public static void setJidCapsMaxCacheSize(int maxCacheSize) { ((Cache) jidCaps).setMaxCacheSize(maxCacheSize); } /** * Sets the maximum Cache size for the nodeVer to DiscoverInfo Cache * * @param maxCacheSize */ @SuppressWarnings("rawtypes") public static void setCapsMaxCacheSize(int maxCacheSize) { ((Cache) caps).setMaxCacheSize(maxCacheSize); } private EntityCapsManager(Connection connection) { this.weakRefConnection = new WeakReference<Connection>(connection); this.sdm = ServiceDiscoveryManager.getInstanceFor(connection); init(); } private void init() { Connection connection = weakRefConnection.get(); instances.put(connection, this); connection.addConnectionListener(new ConnectionListener() { public void connectionClosed() { // Unregister this instance since the connection has been closed presenceSend = false; instances.remove(weakRefConnection.get()); } public void connectionClosedOnError(Exception e) { presenceSend = false; } public void reconnectionFailed(Exception e) { // ignore } public void reconnectingIn(int seconds) { // ignore } public void reconnectionSuccessful() { // ignore } }); // This calculates the local entity caps version updateLocalEntityCaps(); if (SmackConfiguration.autoEnableEntityCaps()) enableEntityCaps(); PacketFilter packetFilter = new AndFilter(new PacketTypeFilter(Presence.class), new PacketExtensionFilter( ELEMENT, NAMESPACE)); connection.addPacketListener(new PacketListener() { // Listen for remote presence stanzas with the caps extension // If we receive such a stanza, record the JID and nodeVer @Override public void processPacket(Packet packet) { if (!entityCapsEnabled()) return; CapsExtension ext = (CapsExtension) packet.getExtension(EntityCapsManager.ELEMENT, EntityCapsManager.NAMESPACE); String hash = ext.getHash().toLowerCase(); if (!SUPPORTED_HASHES.containsKey(hash)) return; String from = packet.getFrom(); String node = ext.getNode(); String ver = ext.getVer(); jidCaps.put(from, new NodeVerHash(node, ver, hash)); } }, packetFilter); packetFilter = new AndFilter(new PacketTypeFilter(Presence.class), new NotFilter(new PacketExtensionFilter( ELEMENT, NAMESPACE))); connection.addPacketListener(new PacketListener() { @Override public void processPacket(Packet packet) { // always remove the JID from the map, even if entityCaps are // disabled String from = packet.getFrom(); jidCaps.remove(from); } }, packetFilter); packetFilter = new PacketTypeFilter(Presence.class); connection.addPacketSendingListener(new PacketListener() { @Override public void processPacket(Packet packet) { presenceSend = true; } }, packetFilter); // Intercept presence packages and add caps data when intended. // XEP-0115 specifies that a client SHOULD include entity capabilities // with every presence notification it sends. PacketFilter capsPacketFilter = new PacketTypeFilter(Presence.class); PacketInterceptor packetInterceptor = new PacketInterceptor() { public void interceptPacket(Packet packet) { if (!entityCapsEnabled) return; CapsExtension caps = new CapsExtension(ENTITY_NODE, getCapsVersion(), "sha-1"); packet.addExtension(caps); } }; connection.addPacketInterceptor(packetInterceptor, capsPacketFilter); // It's important to do this as last action. Since it changes the // behavior of the SDM in some ways sdm.setEntityCapsManager(this); } public static synchronized EntityCapsManager getInstanceFor(Connection connection) { // For testing purposed forbid EntityCaps for non XMPPConnections // it may work on BOSH connections too if (!(connection instanceof XMPPConnection)) return null; if (SUPPORTED_HASHES.size() <= 0) return null; EntityCapsManager entityCapsManager = instances.get(connection); if (entityCapsManager == null) { entityCapsManager = new EntityCapsManager(connection); } return entityCapsManager; } public void enableEntityCaps() { // Add Entity Capabilities (XEP-0115) feature node. sdm.addFeature(NAMESPACE); updateLocalEntityCaps(); entityCapsEnabled = true; } public void disableEntityCaps() { entityCapsEnabled = false; sdm.removeFeature(NAMESPACE); } public boolean entityCapsEnabled() { return entityCapsEnabled; } /** * Remove a record telling what entity caps node a user has. * * @param user * the user (Full JID) */ public void removeUserCapsNode(String user) { jidCaps.remove(user); } /** * Get our own caps version. The version depends on the enabled features. A * caps version looks like '66/0NaeaBKkwk85efJTGmU47vXI=' * * @return our own caps version */ public String getCapsVersion() { return currentCapsVersion; } /** * Returns the local entity's NodeVer (e.g. * "http://www.igniterealtime.org/projects/smack/#66/0NaeaBKkwk85efJTGmU47vXI= * ) * * @return */ public String getLocalNodeVer() { return ENTITY_NODE + '#' + getCapsVersion(); } /** * Returns true if Entity Caps are supported by a given JID * * @param jid * @return */ public boolean areEntityCapsSupported(String jid) { if (jid == null) return false; try { DiscoverInfo result = sdm.discoverInfo(jid); return result.containsFeature(NAMESPACE); } catch (XMPPException e) { return false; } } /** * Returns true if Entity Caps are supported by the local service/server * * @return */ public boolean areEntityCapsSupportedByServer() { return areEntityCapsSupported(weakRefConnection.get().getServiceName()); } /** * Updates the local user Entity Caps information with the data provided * * If we are connected and there was already a presence send, another * presence is send to inform others about your new Entity Caps node string. * * @param discoverInfo * the local users discover info (mostly the service discovery * features) * @param identityType * the local users identity type * @param identityName * the local users identity name * @param extendedInfo * the local users extended info */ public void updateLocalEntityCaps() { Connection connection = weakRefConnection.get(); DiscoverInfo discoverInfo = new DiscoverInfo(); discoverInfo.setType(IQ.Type.RESULT); discoverInfo.setNode(getLocalNodeVer()); if (connection != null) discoverInfo.setFrom(connection.getUser()); sdm.addDiscoverInfoTo(discoverInfo); currentCapsVersion = generateVerificationString(discoverInfo, "sha-1"); addDiscoverInfoByNode(ENTITY_NODE + '#' + currentCapsVersion, discoverInfo); if (lastLocalCapsVersions.size() > 10) { String oldCapsVersion = lastLocalCapsVersions.poll(); sdm.removeNodeInformationProvider(ENTITY_NODE + '#' + oldCapsVersion); } lastLocalCapsVersions.add(currentCapsVersion); caps.put(currentCapsVersion, discoverInfo); if (connection != null) jidCaps.put(connection.getUser(), new NodeVerHash(ENTITY_NODE, currentCapsVersion, "sha-1")); sdm.setNodeInformationProvider(ENTITY_NODE + '#' + currentCapsVersion, new NodeInformationProvider() { List<String> features = sdm.getFeaturesList(); List<Identity> identities = new LinkedList<Identity>(ServiceDiscoveryManager.getIdentities()); List<PacketExtension> packetExtensions = sdm.getExtendedInfoAsList(); @Override public List<Item> getNodeItems() { return null; } @Override public List<String> getNodeFeatures() { return features; } @Override public List<Identity> getNodeIdentities() { return identities; } @Override public List<PacketExtension> getNodePacketExtensions() { return packetExtensions; } }); // Send an empty presence, and let the packet intercepter // add a <c/> node to it. // See http://xmpp.org/extensions/xep-0115.html#advertise // We only send a presence packet if there was already one send // to respect ConnectionConfiguration.isSendPresence() if (connection != null && connection.isAuthenticated() && presenceSend) { Presence presence = new Presence(Presence.Type.available); connection.sendPacket(presence); } } /** * Verify DisoverInfo and Caps Node as defined in XEP-0115 5.4 Processing * Method * * @see <a href="http://xmpp.org/extensions/xep-0115.html#ver-proc">XEP-0115 * 5.4 Processing Method</a> * * @param capsNode * the caps node (i.e. node#ver) * @param info * @return true if it's valid and should be cache, false if not */ public static boolean verifyDiscvoerInfoVersion(String ver, String hash, DiscoverInfo info) { // step 3.3 check for duplicate identities if (info.containsDuplicateIdentities()) return false; // step 3.4 check for duplicate features if (info.containsDuplicateFeatures()) return false; // step 3.5 check for well-formed packet extensions if (verifyPacketExtensions(info)) return false; String calculatedVer = generateVerificationString(info, hash); if (!ver.equals(calculatedVer)) return false; return true; } /** * * @param info * @return true if the packet extensions is ill-formed */ protected static boolean verifyPacketExtensions(DiscoverInfo info) { List<FormField> foundFormTypes = new LinkedList<FormField>(); for (Iterator<PacketExtension> i = info.getExtensions().iterator(); i.hasNext();) { PacketExtension pe = i.next(); if (pe.getNamespace().equals(Form.NAMESPACE)) { DataForm df = (DataForm) pe; for (Iterator<FormField> it = df.getFields(); it.hasNext();) { FormField f = it.next(); if (f.getVariable().equals("FORM_TYPE")) { for (FormField fft : foundFormTypes) { if (f.equals(fft)) return true; } foundFormTypes.add(f); } } } } return false; } /** * Generates a XEP-115 Verification String * * @see <a href="http://xmpp.org/extensions/xep-0115.html#ver">XEP-115 * Verification String</a> * * @param discoverInfo * @param hash * the used hash function * @return The generated verification String or null if the hash is not * supported */ protected static String generateVerificationString(DiscoverInfo discoverInfo, String hash) { MessageDigest md = SUPPORTED_HASHES.get(hash.toLowerCase()); if (md == null) return null; DataForm extendedInfo = (DataForm) discoverInfo.getExtension(Form.ELEMENT, Form.NAMESPACE); // 1. Initialize an empty string S ('sb' in this method). StringBuilder sb = new StringBuilder(); // Use StringBuilder as we don't // need thread-safe StringBuffer // 2. Sort the service discovery identities by category and then by // type and then by xml:lang // (if it exists), formatted as CATEGORY '/' [TYPE] '/' [LANG] '/' // [NAME]. Note that each slash is included even if the LANG or // NAME is not included (in accordance with XEP-0030, the category and // type MUST be included. SortedSet<DiscoverInfo.Identity> sortedIdentities = new TreeSet<DiscoverInfo.Identity>(); ; for (Iterator<DiscoverInfo.Identity> it = discoverInfo.getIdentities(); it.hasNext();) sortedIdentities.add(it.next()); // 3. For each identity, append the 'category/type/lang/name' to S, // followed by the '<' character. for (Iterator<DiscoverInfo.Identity> it = sortedIdentities.iterator(); it.hasNext();) { DiscoverInfo.Identity identity = it.next(); sb.append(identity.getCategory()); sb.append("/"); sb.append(identity.getType()); sb.append("/"); sb.append(identity.getLanguage() == null ? "" : identity.getLanguage()); sb.append("/"); sb.append(identity.getName() == null ? "" : identity.getName()); sb.append("<"); } // 4. Sort the supported service discovery features. SortedSet<String> features = new TreeSet<String>(); for (Iterator<Feature> it = discoverInfo.getFeatures(); it.hasNext();) features.add(it.next().getVar()); // 5. For each feature, append the feature to S, followed by the '<' // character for (String f : features) { sb.append(f); sb.append("<"); } // only use the data form for calculation is it has a hidden FORM_TYPE // field // see XEP-0115 5.4 step 3.6 if (extendedInfo != null && extendedInfo.hasHiddenFromTypeField()) { synchronized (extendedInfo) { // 6. If the service discovery information response includes // XEP-0128 data forms, sort the forms by the FORM_TYPE (i.e., // by the XML character data of the <value/> element). SortedSet<FormField> fs = new TreeSet<FormField>(new Comparator<FormField>() { public int compare(FormField f1, FormField f2) { return f1.getVariable().compareTo(f2.getVariable()); } }); FormField ft = null; for (Iterator<FormField> i = extendedInfo.getFields(); i.hasNext();) { FormField f = i.next(); if (!f.getVariable().equals("FORM_TYPE")) { fs.add(f); } else { ft = f; } } // Add FORM_TYPE values if (ft != null) { formFieldValuesToCaps(ft.getValues(), sb); } // 7. 3. For each field other than FORM_TYPE: // 1. Append the value of the "var" attribute, followed by the // '<' character. // 2. Sort values by the XML character data of the <value/> // element. // 3. For each <value/> element, append the XML character data, // followed by the '<' character. for (FormField f : fs) { sb.append(f.getVariable()); sb.append("<"); formFieldValuesToCaps(f.getValues(), sb); } } } // 8. Ensure that S is encoded according to the UTF-8 encoding (RFC // 3269). // 9. Compute the verification string by hashing S using the algorithm // specified in the 'hash' attribute (e.g., SHA-1 as defined in RFC // 3174). // The hashed data MUST be generated with binary output and // encoded using Base64 as specified in Section 4 of RFC 4648 // (note: the Base64 output MUST NOT include whitespace and MUST set // padding bits to zero). byte[] digest = md.digest(sb.toString().getBytes()); return Base64.encodeBytes(digest); } private static void formFieldValuesToCaps(Iterator<String> i, StringBuilder sb) { SortedSet<String> fvs = new TreeSet<String>(); while (i.hasNext()) { fvs.add(i.next()); } for (String fv : fvs) { sb.append(fv); sb.append("<"); } } public static class NodeVerHash { private String node; private String hash; private String ver; private String nodeVer; NodeVerHash(String node, String ver, String hash) { this.node = node; this.ver = ver; this.hash = hash; nodeVer = node + "#" + ver; } public String getNodeVer() { return nodeVer; } public String getNode() { return node; } public String getHash() { return hash; } public String getVer() { return ver; } } }