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}