001/*
002 *   Copyright 2024 Vonage
003 *
004 *   Licensed under the Apache License, Version 2.0 (the "License");
005 *   you may not use this file except in compliance with the License.
006 *   You may obtain a copy of the License at
007 *
008 *        http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *   Unless required by applicable law or agreed to in writing, software
011 *   distributed under the License is distributed on an "AS IS" BASIS,
012 *   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *   See the License for the specific language governing permissions and
014 *   limitations under the License.
015 */
016package com.vonage.client.auth;
017
018import com.fasterxml.jackson.core.type.TypeReference;
019import com.fasterxml.jackson.databind.ObjectMapper;
020import com.vonage.client.VonageUnexpectedException;
021import com.vonage.client.auth.hashutils.HashUtil;
022import org.apache.commons.logging.Log;
023import org.apache.commons.logging.LogFactory;
024import org.apache.http.NameValuePair;
025import java.io.IOException;
026import java.io.InputStream;
027import java.nio.charset.StandardCharsets;
028import java.security.MessageDigest;
029import java.time.Instant;
030import java.util.*;
031import java.util.stream.Collectors;
032
033/**
034 * A helper class for generating or verifying MD5 signatures when signing REST requests for submission to Vonage.
035 */
036public class RequestSigning {
037    public static final int MAX_ALLOWABLE_TIME_DELTA = 5 * 60 * 1000;
038
039    public static final String PARAM_SIGNATURE = "sig";
040    public static final String PARAM_TIMESTAMP = "timestamp";
041    public static final String APPLICATION_JSON = "application/json";
042
043    private static final Log log = LogFactory.getLog(RequestSigning.class);
044
045    /**
046     * Signs a set of request parameters.
047     * <p>
048     * Generates additional parameters to represent the timestamp and generated signature.
049     * Uses the supplied pre-shared secret key to generate the signature.
050     * Uses the default hash strategy of MD5.
051     *
052     * @param params List of NameValuePair instances containing the query parameters for the request that is to be signed
053     * @param secretKey the pre-shared secret key held by the client
054     *
055     * @deprecated Use {@link #getSignatureForRequestParameters(Map, String, HashUtil.HashType)}.
056     */
057    @Deprecated
058    public static void constructSignatureForRequestParameters(List<NameValuePair> params, String secretKey) {
059        constructSignatureForRequestParameters(params, secretKey, HashUtil.HashType.HMAC_MD5);
060    }
061
062    /**
063     * Signs a set of request parameters.
064     * <p>
065     * Generates additional parameters to represent the timestamp and generated signature.
066     * Uses the supplied pre-shared secret key to generate the signature.
067     *
068     * @param params List of NameValuePair instances containing the query parameters for the request that is to be signed
069     * @param secretKey the pre-shared secret key held by the client.
070     * @param hashType The type of hash that is to be used in construction.
071     * @deprecated Use {@link #constructSignatureForRequestParameters(Map, String, HashUtil.HashType)}.
072     */
073    @Deprecated
074    public static void constructSignatureForRequestParameters(List<NameValuePair> params, String secretKey, HashUtil.HashType hashType) {
075        Map<String, String> sortedParams = params.stream().collect(Collectors.toMap(
076                NameValuePair::getName,
077                NameValuePair::getValue,
078                (v1, v2) -> v1,
079                TreeMap::new
080        ));
081        constructSignatureForRequestParameters(sortedParams, secretKey, hashType);
082    }
083
084    /**
085     * Signs a set of request parameters.
086     * <p>
087     * Generates additional parameters to represent the timestamp and generated signature.
088     * Uses the supplied pre-shared secret key to generate the signature.
089     * This method modifies the input params.
090     *
091     * @param params Query parameters for the request that is to be signed.
092     * @param secretKey the pre-shared secret key held by the client.
093     * @param hashType The type of hash that is to be used in construction.
094     *
095     * @deprecated Use {@link #getSignatureForRequestParameters(Map, String, HashUtil.HashType)}.
096     */
097    @Deprecated
098    public static void constructSignatureForRequestParameters(Map<String, String> params, String secretKey, HashUtil.HashType hashType) {
099        params.putAll(getSignatureForRequestParameters(params, secretKey, hashType));
100    }
101
102    /**
103     * Signs a set of request parameters.
104     * <p>
105     * Generates additional parameters to represent the timestamp and generated signature.
106     * Uses the supplied pre-shared secret key to generate the signature.
107     * This method does not modify the input parameters.
108     *
109     * @param params Query parameters for the request that is to be signed.
110     * @param secretKey the pre-shared secret key held by the client.
111     * @param hashType The type of hash that is to be used in construction.
112     *
113     * @return A new Map with the signature query parameters.
114     */
115    public static Map<String, String> getSignatureForRequestParameters(Map<String, String> params, String secretKey, HashUtil.HashType hashType) {
116        return constructSignatureForRequestParameters(params, secretKey, Instant.now().getEpochSecond(), hashType);
117    }
118
119    private static String clean(String str) {
120        return str == null ? null : str.replaceAll("[=&]", "_");
121    }
122
123    static String generateParamsString(Map<String, String> params) {
124        SortedMap<String, String> sortedParams = params instanceof SortedMap ?
125                (SortedMap<String, String>) params : new TreeMap<>(params);
126
127        StringBuilder sb = new StringBuilder();
128        for (Map.Entry<String, String> param : sortedParams.entrySet()) {
129            String name = param.getKey(), value = param.getValue();
130            if (PARAM_SIGNATURE.equals(name) || value == null || value.trim().isEmpty()) {
131                continue;
132            }
133            sb.append("&").append(clean(name)).append("=").append(clean(value));
134        }
135        return sb.toString();
136    }
137
138    /**
139     * Signs a set of request parameters.
140     * <p>
141     * Generates additional parameters to represent the timestamp and generated signature.
142     * Uses the supplied pre-shared secret key to generate the signature.
143     *
144     * @param inputParams Query parameters for the request that is to be signed.
145     * @param secretKey the pre-shared secret key held by the client.
146     * @param currentTimeSeconds the current time in seconds since 1970-01-01.
147     * @param hashType Hash type to be used to construct request parameters.
148     *
149     */
150    static Map<String, String> constructSignatureForRequestParameters(Map<String, String> inputParams,
151                                                                 String secretKey,
152                                                                 long currentTimeSeconds,
153                                                                 HashUtil.HashType hashType) {
154
155        // First, inject a 'timestamp=' parameter containing the current time in seconds since Jan 1st 1970
156        String timestampStr = Long.toString(currentTimeSeconds);
157        Map<String, String> tempParams = new TreeMap<>(inputParams);
158        tempParams.put(PARAM_TIMESTAMP, timestampStr);
159
160        String hashed, str = generateParamsString(tempParams);
161        try {
162            hashed = HashUtil.calculate(str, secretKey, "UTF-8", hashType);
163        }
164        catch (Exception ex) {
165            log.error("error...", ex);
166            hashed = "no signature";
167        }
168
169        log.debug("SECURITY-KEY-GENERATION -- String [ " + str + " ] Signature [ " + hashed + " ] ");
170
171        Map<String, String> outputParams = new LinkedHashMap<>(4);
172        outputParams.put(PARAM_TIMESTAMP, timestampStr);
173        outputParams.put(PARAM_SIGNATURE, hashed);
174        return outputParams;
175    }
176
177    /**
178     * Verifies the signature in an HttpServletRequest. Hashing strategy is MD5.
179     *
180     * @param contentType The request Content-Type header.
181     * @param inputStream The request data stream.
182     * @param parameterMap The request parameters.
183     * @param secretKey The pre-shared secret key used by the sender of the request to create the signature.
184     *
185     * @return true if the signature is correct for this request and secret key.
186     *
187     * @since 8.0.0
188     */
189    public static boolean verifyRequestSignature(InputStream inputStream,
190                                                    String contentType,
191                                                    Map<String, String[]> parameterMap,
192                                                    String secretKey) {
193        return verifyRequestSignature(contentType, inputStream, parameterMap, secretKey, System.currentTimeMillis());
194    }
195
196    /**
197     * Verifies the signature in an HttpServletRequest. Hashing strategy is MD5.
198     *
199     * @param contentType The request Content-Type header.
200     * @param inputStream The request data stream.
201     * @param parameterMap The request parameters.
202     * @param secretKey The pre-shared secret key used by the sender of the request to create the signature.
203     * @param currentTimeMillis The current time, in milliseconds.
204     *
205     * @return true if the signature is correct for this request and secret key.
206     */
207     protected static boolean verifyRequestSignature(String contentType,
208                                                     InputStream inputStream,
209                                                     Map<String, String[]> parameterMap,
210                                                     String secretKey,
211                                                     long currentTimeMillis) {
212        return verifyRequestSignature(contentType, inputStream, parameterMap,
213                secretKey, currentTimeMillis, HashUtil.HashType.MD5
214        );
215    }
216
217    /**
218     * Verifies the signature in an HttpServletRequest.
219     *
220     * @param contentType The request Content-Type header.
221     * @param inputStream The request data stream.
222     * @param parameterMap The request parameters.
223     * @param secretKey The pre-shared secret key used by the sender of the request to create the signature.
224     * @param currentTimeMillis The current time, in milliseconds.
225     * @param hashType Hash type to be used to construct request parameters.
226     *
227     * @return true if the signature is correct for this request and secret key.
228     */
229    static boolean verifyRequestSignature(String contentType,
230                                                    InputStream inputStream,
231                                                    Map<String, String[]> parameterMap,
232                                                    String secretKey,
233                                                    long currentTimeMillis,
234                                                    HashUtil.HashType hashType) {
235
236        // Construct a sorted list of the name-value pair parameters supplied in the request, excluding the signature parameter
237        Map<String, String> sortedParams = new TreeMap<>();
238        if (APPLICATION_JSON.equals(contentType) && inputStream != null) {
239            ObjectMapper mapper = new ObjectMapper();
240            try {
241                Map<String,String> params = mapper.readValue(inputStream, new TypeReference<Map<String,String>>(){});
242                for (Map.Entry<String, String> entry : params.entrySet()) {
243                    String name = entry.getKey();
244                    String value = entry.getValue();
245                    log.info(name + " = " + value);
246                    if (value == null || value.trim().isEmpty()) {
247                        continue;
248                    }
249                    sortedParams.put(name, value);
250                }
251            }
252            catch (IOException ex) {
253                throw new VonageUnexpectedException("Unexpected issue when parsing JSON", ex);
254            }
255        }
256        else {
257            for (Map.Entry<String, String[]> entry : parameterMap.entrySet()) {
258                String name = entry.getKey();
259                String value = entry.getValue()[0];
260                log.info(name + " = " + value);
261                if (value == null || value.trim().isEmpty()) {
262                    continue;
263                }
264                sortedParams.put(name, value);
265            }
266        }
267
268        // identify the signature supplied in the request ...
269        String suppliedSignature = sortedParams.get(PARAM_SIGNATURE);
270        if (suppliedSignature == null) return false;
271
272        // Extract the timestamp parameter and verify that it is within 5 minutes of 'current time'
273        String timeString = sortedParams.get(PARAM_TIMESTAMP);
274        long time = -1;
275        try {
276            if (timeString != null)
277                time = Long.parseLong(timeString) * 1000;
278        } catch (NumberFormatException e) {
279            log.error("Error parsing 'time' parameter [ " + timeString + " ]", e);
280            time = 0;
281        }
282        long diff = currentTimeMillis - time;
283        if (diff > MAX_ALLOWABLE_TIME_DELTA || diff < -MAX_ALLOWABLE_TIME_DELTA) {
284            log.warn("SECURITY-KEY-VERIFICATION -- BAD-TIMESTAMP ... Timestamp [ " + time + " ] delta [ " + diff + " ] max allowed delta [ " + -MAX_ALLOWABLE_TIME_DELTA + " ] ");
285            return false;
286        }
287
288
289        // Walk this sorted list of parameters and construct a string
290        StringBuilder sb = new StringBuilder();
291        for (Map.Entry<String, String> param : sortedParams.entrySet()) {
292            if (param.getKey().equals(PARAM_SIGNATURE)) continue;
293            String name = param.getKey();
294            String value = param.getValue();
295            sb.append("&").append(clean(name)).append("=").append(clean(value));
296        }
297
298        String str = sb.toString();
299
300        String hashed;
301        try {
302            hashed = HashUtil.calculate(str, secretKey, "UTF-8", hashType);
303        } catch (Exception e) {
304            log.error("error...", e);
305            return false;
306        }
307
308        log.info("SECURITY-KEY-VERIFICATION -- String [ " + str + " ] Signature [ " + hashed + " ] SUPPLIED SIGNATURE [ " + suppliedSignature + " ] ");
309
310        // verify that the supplied signature matches generated one
311        // use MessageDigest.isEqual as an alternative to String.equals() to defend against timing based attacks
312        return MessageDigest.isEqual(
313                hashed.toLowerCase().getBytes(StandardCharsets.UTF_8),
314                suppliedSignature.toLowerCase().getBytes(StandardCharsets.UTF_8)
315        );
316    }
317}