001/*
002 * Copyright (c) 2011-2017 Nexmo Inc
003 *
004 * Permission is hereby granted, free of charge, to any person obtaining a copy
005 * of this software and associated documentation files (the "Software"), to deal
006 * in the Software without restriction, including without limitation the rights
007 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
008 * copies of the Software, and to permit persons to whom the Software is
009 * furnished to do so, subject to the following conditions:
010 *
011 * The above copyright notice and this permission notice shall be included in
012 * all copies or substantial portions of the Software.
013 *
014 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
015 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
016 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
017 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
018 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
019 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
020 * THE SOFTWARE.
021 */
022package com.nexmo.client.auth;
023
024
025import java.io.UnsupportedEncodingException;
026import java.security.MessageDigest;
027import java.util.List;
028import java.util.Map;
029import java.util.TreeMap;
030
031import javax.servlet.http.HttpServletRequest;
032
033import com.nexmo.client.NexmoUnexpectedException;
034import org.apache.commons.logging.Log;
035import org.apache.commons.logging.LogFactory;
036import org.apache.http.NameValuePair;
037import org.apache.http.message.BasicNameValuePair;
038
039/**
040 * A helper class for generating or verifying MD5 signatures when signing REST requests for submission to Nexmo.
041 *
042 * @author  Paul Cook
043 */
044public class RequestSigning {
045    public static final int MAX_ALLOWABLE_TIME_DELTA = 5 * 60 * 1000;
046
047    public static final String PARAM_SIGNATURE = "sig";
048    public static final String PARAM_TIMESTAMP = "timestamp";
049
050    private static Log log = LogFactory.getLog(RequestSigning.class);
051
052    /**
053     * Signs a set of request parameters.
054     * <p>
055     * Generates additional parameters to represent the timestamp and generated signature.
056     * Uses the supplied pre-shared secret key to generate the signature.
057     *
058     * @param params List of NameValuePair instances containing the query parameters for the request that is to be signed
059     * @param secretKey the pre-shared secret key held by the client
060     *
061     */
062    public static void constructSignatureForRequestParameters(List<NameValuePair> params, String secretKey) {
063        constructSignatureForRequestParameters(params, secretKey, System.currentTimeMillis() / 1000);
064    }
065
066    /**
067     * Signs a set of request parameters.
068     * <p>
069     * Generates additional parameters to represent the timestamp and generated signature.
070     * Uses the supplied pre-shared secret key to generate the signature.
071     *
072     * @param params List of NameValuePair instances containing the query parameters for the request that is to be signed
073     * @param secretKey the pre-shared secret key held by the client
074     * @param currentTimeSeconds the current time in seconds since 1970-01-01
075     *
076     */
077     protected static void constructSignatureForRequestParameters(
078            List<NameValuePair> params, String secretKey, long currentTimeSeconds) {
079        // First, inject a 'timestamp=' parameter containing the current time in seconds since Jan 1st 1970
080        params.add(new BasicNameValuePair(PARAM_TIMESTAMP, Long.toString(currentTimeSeconds)));
081
082        Map<String, String> sortedParams = new TreeMap<>();
083        for (NameValuePair param: params) {
084            String name = param.getName();
085            String value = param.getValue();
086            if (name.equals(PARAM_SIGNATURE))
087                continue;
088            if (value == null)
089                value = "";
090            if (!value.trim().equals(""))
091                sortedParams.put(name, value);
092        }
093
094        // Now, walk through the sorted list of parameters and construct a string
095        StringBuilder sb = new StringBuilder();
096        for (Map.Entry<String, String> param: sortedParams.entrySet()) {
097            String name = param.getKey();
098            String value = param.getValue();
099            sb.append("&").append(clean(name)).append("=").append(clean(value));
100        }
101
102        // Now, append the secret key, and calculate an MD5 signature of the resultant string
103        sb.append(secretKey);
104
105        String str = sb.toString();
106
107        String md5 = "no signature";
108        try {
109            md5 = MD5Util.calculateMd5(str);
110        } catch (Exception e) {
111            log.error("error...", e);
112        }
113
114        log.debug("SECURITY-KEY-GENERATION -- String [ " + str + " ] Signature [ " + md5 + " ] ");
115
116        params.add(new BasicNameValuePair(PARAM_SIGNATURE, md5));
117    }
118
119    /**
120     * Verifies the signature in an HttpServletRequest.
121     *
122     * @param request The HttpServletRequest to be verified
123     * @param secretKey The pre-shared secret key used by the sender of the request to create the signature
124     *
125     * @return true if the signature is correct for this request and secret key.
126     */
127    public static boolean verifyRequestSignature(HttpServletRequest request, String secretKey) {
128        return verifyRequestSignature(request, secretKey, System.currentTimeMillis());
129    }
130
131    /**
132     * Verifies the signature in an HttpServletRequest.
133     *
134     * @param request The HttpServletRequest to be verified
135     * @param secretKey The pre-shared secret key used by the sender of the request to create the signature
136     * @param currentTimeMillis The current time, in milliseconds.
137     *
138     * @return true if the signature is correct for this request and secret key.
139     */
140     protected static boolean verifyRequestSignature(HttpServletRequest request,
141                                                     String secretKey,
142                                                     long currentTimeMillis) {
143        // identify the signature supplied in the request ...
144        String suppliedSignature = request.getParameter(PARAM_SIGNATURE);
145        if (suppliedSignature == null)
146            return false;
147
148        // Firstly, extract the timestamp parameter and verify that it is within 5 minutes of 'current time'
149        String timeString = request.getParameter(PARAM_TIMESTAMP);
150        long time = -1;
151        try {
152            if (timeString != null)
153                time = Long.parseLong(timeString) * 1000;
154        } catch (NumberFormatException e) {
155            log.error("Error parsing 'time' parameter [ " + timeString + " ]", e);
156            time = 0;
157        }
158        long diff = currentTimeMillis - time;
159        if (diff > MAX_ALLOWABLE_TIME_DELTA || diff < -MAX_ALLOWABLE_TIME_DELTA) {
160            log.warn("SECURITY-KEY-VERIFICATION -- BAD-TIMESTAMP ... Timestamp [ " + time + " ] delta [ " + diff + " ] max allowed delta [ " + -MAX_ALLOWABLE_TIME_DELTA + " ] ");
161            return false;
162        }
163
164        // Next, construct a sorted list of the name-value pair parameters supplied in the request, excluding the signature parameter
165        Map<String, String> sortedParams = new TreeMap<>();
166        for (Map.Entry<String, String[]> entry: request.getParameterMap().entrySet()) {
167            String name = entry.getKey();
168            String value = entry.getValue()[0];
169            log.info("" + name + " = " + value);
170            if (name.equals(PARAM_SIGNATURE))
171                continue;
172            if (value == null || value.trim().equals("")) {
173                continue;
174            }
175            sortedParams.put(name, value);
176        }
177
178        // walk this sorted list of parameters and construct a string
179        StringBuilder sb = new StringBuilder();
180        for (Map.Entry<String, String> param: sortedParams.entrySet()) {
181            String name = param.getKey();
182            String value = param.getValue();
183            sb.append("&").append(clean(name)).append("=").append(clean(value));
184        }
185
186        // append the secret key and calculate an md5 signature of the resultant string
187        sb.append(secretKey);
188
189        String str = sb.toString();
190
191        String md5;
192        try {
193            md5 = MD5Util.calculateMd5(str);
194        } catch (Exception e) {
195            log.error("error...", e);
196            return false;
197        }
198
199        log.info("SECURITY-KEY-VERIFICATION -- String [ " + str + " ] Signature [ " + md5 + " ] SUPPLIED SIGNATURE [ " + suppliedSignature + " ] ");
200
201        // verify that the supplied signature matches generated one
202        // use MessageDigest.isEqual as an alternative to String.equals() to defend against timing based attacks
203        try {
204            if (!MessageDigest.isEqual(md5.getBytes("UTF-8"), suppliedSignature.getBytes("UTF-8")))
205                return false;
206        } catch (UnsupportedEncodingException e) {
207            throw new NexmoUnexpectedException("Failed to decode signature as UTF-8", e);
208        }
209
210        return true;
211    }
212
213    public static String clean(String str) {
214        return str == null ? null : str.replaceAll("[=&]", "_");
215    }
216
217}