Skip to content

Commit 50099c3

Browse files
author
RTLcoil
authored
Add signature checking methods (#193)
1 parent 7dee39e commit 50099c3

File tree

10 files changed

+359
-33
lines changed

10 files changed

+359
-33
lines changed

cloudinary-core/src/main/java/com/cloudinary/Cloudinary.java

Lines changed: 42 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.cloudinary;
22

3+
import com.cloudinary.api.signing.ApiResponseSignatureVerifier;
4+
import com.cloudinary.api.signing.NotificationRequestSignatureVerifier;
35
import com.cloudinary.strategies.AbstractApiStrategy;
46
import com.cloudinary.strategies.AbstractUploaderStrategy;
57
import com.cloudinary.strategies.StrategyLoader;
@@ -8,8 +10,6 @@
810

911
import java.io.UnsupportedEncodingException;
1012
import java.net.URLEncoder;
11-
import java.security.MessageDigest;
12-
import java.security.NoSuchAlgorithmException;
1313
import java.security.SecureRandom;
1414
import java.util.*;
1515

@@ -127,27 +127,46 @@ public String signedPreloadedImage(Map result) {
127127
}
128128

129129
public String apiSignRequest(Map<String, Object> paramsToSign, String apiSecret) {
130-
Collection<String> params = new ArrayList<String>();
131-
for (Map.Entry<String, Object> param : new TreeMap<String, Object>(paramsToSign).entrySet()) {
132-
if (param.getValue() instanceof Collection) {
133-
params.add(param.getKey() + "=" + StringUtils.join((Collection) param.getValue(), ","));
134-
} else if (param.getValue() instanceof Object[]) {
135-
params.add(param.getKey() + "=" + StringUtils.join((Object[]) param.getValue(), ","));
136-
} else {
137-
if (StringUtils.isNotBlank(param.getValue())) {
138-
params.add(param.getKey() + "=" + param.getValue().toString());
139-
}
140-
}
141-
}
142-
String to_sign = StringUtils.join(params, "&");
143-
MessageDigest md = null;
144-
try {
145-
md = MessageDigest.getInstance("SHA-1");
146-
} catch (NoSuchAlgorithmException e) {
147-
throw new RuntimeException("Unexpected exception", e);
148-
}
149-
byte[] digest = md.digest(getUTF8Bytes(to_sign + apiSecret));
150-
return StringUtils.encodeHexString(digest);
130+
return Util.produceSignature(paramsToSign, apiSecret);
131+
}
132+
133+
/**
134+
* Verifies that Cloudinary notification request is genuine by checking its signature.
135+
*
136+
* Cloudinary can asynchronously process your e.g. image uploads requests. This is achieved by calling back API you
137+
* specified during preparing of upload request as soon as it has been processed. See Upload Notifications in
138+
* Cloudinary documentation for more details. In order to make sure it is Cloudinary calling your API back, hashed
139+
* message authentication codes (HMAC's) based on SHA-1 hashing function and configured Cloudinary API secret key
140+
* are used for signing the requests.
141+
*
142+
* The following method serves as a convenient utility to perform the verification procedure.
143+
*
144+
* @param body Cloudinary Notification request body represented as string
145+
* @param timestamp Cloudinary Notification request custom X-Cld-Timestamp HTTP header value
146+
* @param signature Cloudinary Notification request custom X-Cld-Signature HTTP header value, i.e. the HMAC
147+
* @param validFor desired period of request validity since issued, in seconds, for protection against replay attacks
148+
* @return whether request signature is valid or not
149+
*/
150+
public boolean verifyNotificationSignature(String body, String timestamp, String signature, long validFor) {
151+
return new NotificationRequestSignatureVerifier(config.apiSecret).verifySignature(body, timestamp, signature, validFor);
152+
}
153+
154+
/**
155+
* Verifies that Cloudinary API response is genuine by checking its signature.
156+
*
157+
* Cloudinary can add a signature value in the response to API methods returning public id's and versions. In order
158+
* to make sure it is genuine Cloudinary response, hashed message authentication codes (HMAC's) based on SHA-1 hashing
159+
* function and configured Cloudinary API secret key are used for signing the responses.
160+
*
161+
* The following method serves as a convenient utility to perform the verification procedure.
162+
*
163+
* @param publicId publicId response field value
164+
* @param version version response field value
165+
* @param signature signature response field value, i.e. the HMAC
166+
* @return whether response signature is valid or not
167+
*/
168+
public boolean verifyApiResponseSignature(String publicId, String version, String signature) {
169+
return new ApiResponseSignatureVerifier(config.apiSecret).verifySignature(publicId, version, signature);
151170
}
152171

153172
public void signRequest(Map<String, Object> params, Map<String, Object> options) {
@@ -236,14 +255,6 @@ private String buildUrl(String base, Map<String, Object> params) throws Unsuppor
236255
return urlBuilder.toString();
237256
}
238257

239-
byte[] getUTF8Bytes(String string) {
240-
try {
241-
return string.getBytes("UTF-8");
242-
} catch (java.io.UnsupportedEncodingException e) {
243-
throw new RuntimeException("Unexpected exception", e);
244-
}
245-
}
246-
247258
@Deprecated
248259
public static Map asMap(Object... values) {
249260
return ObjectUtils.asMap(values);

cloudinary-core/src/main/java/com/cloudinary/Url.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,7 @@ public String generate(String source) {
393393
toSign = StringUtils.removeStartingChars(toSign, '/');
394394
toSign = StringUtils.mergeSlashesInUrl(toSign);
395395

396-
byte[] digest = md.digest(cloudinary.getUTF8Bytes(toSign + this.config.apiSecret));
396+
byte[] digest = md.digest(Util.getUTF8Bytes(toSign + this.config.apiSecret));
397397
signature = Base64Coder.encodeURLSafeString(digest);
398398
signature = "s--" + signature.substring(0, 8) + "--";
399399
}
@@ -536,7 +536,7 @@ public String unsignedDownloadUrlPrefix(String source, String cloudName, boolean
536536

537537
private String shard(String input) {
538538
CRC32 crc32 = new CRC32();
539-
crc32.update(cloudinary.getUTF8Bytes(input));
539+
crc32.update(Util.getUTF8Bytes(input));
540540
return String.valueOf((crc32.getValue() % 5 + 5) % 5 + 1);
541541
}
542542

cloudinary-core/src/main/java/com/cloudinary/Util.java

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import com.cloudinary.utils.StringUtils;
55
import org.cloudinary.json.JSONObject;
66

7+
import java.security.MessageDigest;
8+
import java.security.NoSuchAlgorithmException;
79
import java.util.*;
810

911
public class Util {
@@ -253,4 +255,55 @@ private static void putArray(String name, Map from, Map<String, Object> to) {
253255
protected static String timestamp() {
254256
return Long.toString(System.currentTimeMillis() / 1000L);
255257
}
258+
259+
/**
260+
* Encodes passed string value into a sequence of bytes using the UTF-8 charset.
261+
*
262+
* @param string string value to encode
263+
* @return byte array representing passed string value
264+
*/
265+
public static byte[] getUTF8Bytes(String string) {
266+
try {
267+
return string.getBytes("UTF-8");
268+
} catch (java.io.UnsupportedEncodingException e) {
269+
throw new RuntimeException("Unexpected exception", e);
270+
}
271+
}
272+
273+
/**
274+
* Calculates signature, or hashed message authentication code (HMAC) of provided parameters name-value pairs and
275+
* secret value using SHA-1 hashing algorithm.
276+
* <p>
277+
* Argument for hashing function is built by joining sorted parameter name-value pairs into single string in the
278+
* same fashion as HTTP GET method uses, and concatenating the result with secret value in the end. Method supports
279+
* arrays/collections as parameter values. In this case, the elements of array/collection are joined into single
280+
* comma-delimited string prior to inclusion into the result.
281+
*
282+
* @param paramsToSign parameter name-value pairs list represented as instance of {@link Map}
283+
* @param apiSecret secret value
284+
* @return hex-string representation of signature calculated based on provided parameters map and secret
285+
*/
286+
public static String produceSignature(Map<String, Object> paramsToSign, String apiSecret) {
287+
Collection<String> params = new ArrayList<String>();
288+
for (Map.Entry<String, Object> param : new TreeMap<String, Object>(paramsToSign).entrySet()) {
289+
if (param.getValue() instanceof Collection) {
290+
params.add(param.getKey() + "=" + StringUtils.join((Collection) param.getValue(), ","));
291+
} else if (param.getValue() instanceof Object[]) {
292+
params.add(param.getKey() + "=" + StringUtils.join((Object[]) param.getValue(), ","));
293+
} else {
294+
if (StringUtils.isNotBlank(param.getValue())) {
295+
params.add(param.getKey() + "=" + param.getValue().toString());
296+
}
297+
}
298+
}
299+
String to_sign = StringUtils.join(params, "&");
300+
MessageDigest md = null;
301+
try {
302+
md = MessageDigest.getInstance("SHA-1");
303+
} catch (NoSuchAlgorithmException e) {
304+
throw new RuntimeException("Unexpected exception", e);
305+
}
306+
byte[] digest = md.digest(getUTF8Bytes(to_sign + apiSecret));
307+
return StringUtils.encodeHexString(digest);
308+
}
256309
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package com.cloudinary.api.signing;
2+
3+
import com.cloudinary.Util;
4+
import com.cloudinary.utils.ObjectUtils;
5+
import com.cloudinary.utils.StringUtils;
6+
7+
import static com.cloudinary.utils.StringUtils.emptyIfNull;
8+
9+
/**
10+
* The {@code ApiResponseSignatureVerifier} class is responsible for verifying Cloudinary Upload API response signatures.
11+
*/
12+
public class ApiResponseSignatureVerifier {
13+
private final String secretKey;
14+
15+
/**
16+
* Initializes new instance of {@code ApiResponseSignatureVerifier} class with a secret key required to perform
17+
* API response signatures verification.
18+
*
19+
* @param secretKey shared secret key string which is used to sign and verify authenticity of API responses
20+
*/
21+
public ApiResponseSignatureVerifier(String secretKey) {
22+
if (StringUtils.isBlank(secretKey)) {
23+
throw new IllegalArgumentException("Secret key is required");
24+
}
25+
26+
this.secretKey = secretKey;
27+
}
28+
29+
/**
30+
* Checks whether particular Cloudinary Upload API response signature matches expected signature.
31+
*
32+
* The task is performed by generating signature using same hashing algorithm as used on Cloudinary servers and
33+
* comparing the result with provided actual signature.
34+
*
35+
* @param publicId public id of uploaded resource as stated in upload API response
36+
* @param version version of uploaded resource as stated in upload API response
37+
* @param signature signature of upload API response, usually passed via X-Cld-Signature custom HTTP response header
38+
*
39+
* @return true if response signature passed verification procedure
40+
*/
41+
public boolean verifySignature(String publicId, String version, String signature) {
42+
return Util.produceSignature(ObjectUtils.asMap(
43+
"public_id", emptyIfNull(publicId),
44+
"version", emptyIfNull(version)), secretKey).equals(signature);
45+
}
46+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package com.cloudinary.api.signing;
2+
3+
import static com.cloudinary.utils.StringUtils.emptyIfNull;
4+
5+
/**
6+
* The {@code NotificationRequestSignatureVerifier} class is responsible for verifying authenticity and integrity
7+
* of Cloudinary Upload notifications.
8+
*/
9+
public class NotificationRequestSignatureVerifier {
10+
private final SignedPayloadValidator signedPayloadValidator;
11+
12+
/**
13+
* Initializes new instance of {@code NotificationRequestSignatureVerifier} with secret key value.
14+
*
15+
* @param secretKey shared secret key string which is used to sign and verify authenticity of notifications
16+
*/
17+
public NotificationRequestSignatureVerifier(String secretKey) {
18+
this.signedPayloadValidator = new SignedPayloadValidator(secretKey);
19+
}
20+
21+
/**
22+
* Verifies signature of Cloudinary Upload notification.
23+
*
24+
* @param body notification message body, represented as string
25+
* @param timestamp value of X-Cld-Timestamp custom HTTP header of notification message, representing notification
26+
* issue timestamp
27+
* @param signature actual signature value, usually passed via X-Cld-Signature custom HTTP header of notification
28+
* message
29+
* @return true if notification passed verification procedure
30+
*/
31+
public boolean verifySignature(String body, String timestamp, String signature) {
32+
return signedPayloadValidator.validateSignedPayload(
33+
emptyIfNull(body) + emptyIfNull(timestamp),
34+
signature);
35+
}
36+
37+
/**
38+
* Verifies signature of Cloudinary Upload notification.
39+
* <p>
40+
* Differs from {@link #verifySignature(String, String, String)} in additional validation which consists of making
41+
* sure the notification being verified is still not expired based on timestamp parameter value.
42+
*
43+
* @param body notification message body, represented as string
44+
* @param timestamp value of X-Cld-Timestamp custom HTTP header of notification message, representing notification
45+
* issue timestamp
46+
* @param signature actual signature value, usually passed via X-Cld-Signature custom HTTP header of notification
47+
* message
48+
* @param secondsValidFor the amount of time, in seconds, the notification message is considered valid by client
49+
* @return true if notification passed verification procedure
50+
*/
51+
public boolean verifySignature(String body, String timestamp, String signature, long secondsValidFor) {
52+
long parsedTimestamp;
53+
try {
54+
parsedTimestamp = Long.parseLong(timestamp);
55+
} catch (NumberFormatException e) {
56+
throw new IllegalArgumentException("Provided timestamp is not a valid number", e);
57+
}
58+
59+
return verifySignature(body, timestamp, signature) &&
60+
(System.currentTimeMillis() - parsedTimestamp <= secondsValidFor * 1000L);
61+
}
62+
63+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package com.cloudinary.api.signing;
2+
3+
import com.cloudinary.Util;
4+
import com.cloudinary.utils.StringUtils;
5+
6+
import java.security.MessageDigest;
7+
import java.security.NoSuchAlgorithmException;
8+
9+
import static com.cloudinary.utils.StringUtils.emptyIfNull;
10+
11+
class SignedPayloadValidator {
12+
private final String secretKey;
13+
private final MessageDigest messageDigest;
14+
15+
SignedPayloadValidator(String secretKey) {
16+
if (StringUtils.isBlank(secretKey)) {
17+
throw new IllegalArgumentException("Secret key is required");
18+
}
19+
20+
this.secretKey = secretKey;
21+
this.messageDigest = acquireMessageDigest();
22+
}
23+
24+
boolean validateSignedPayload(String signedPayload, String signature) {
25+
String expectedSignature =
26+
StringUtils.encodeHexString(
27+
messageDigest.digest(Util.getUTF8Bytes(emptyIfNull(signedPayload) + secretKey)));
28+
29+
return expectedSignature.equals(signature);
30+
}
31+
32+
private static MessageDigest acquireMessageDigest() {
33+
try {
34+
return MessageDigest.getInstance("SHA-1");
35+
} catch (NoSuchAlgorithmException e) {
36+
throw new RuntimeException("Unexpected exception", e);
37+
}
38+
}
39+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/**
2+
* The package holds classes used internally to implement verification procedures of authenticity and integrity of
3+
* client communication with Cloudinary servers. Verification is in most cases based on calculating and comparing so called
4+
* signatures, or hashed message authentication codes (HMAC) - string values calculated based on message payload, some
5+
* secret key value shared between communicating parties and SHA-1 hashing function.
6+
*/
7+
package com.cloudinary.api.signing;

cloudinary-core/src/main/java/com/cloudinary/utils/StringUtils.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,4 +398,14 @@ public static String mergeSlashesInUrl(String url) {
398398

399399
return builder.toString();
400400
}
401+
402+
/**
403+
* Returns empty string value when passed string value is null or empty, the passed string itself otherwise.
404+
*
405+
* @param str string value to evaluate
406+
* @return passed string value or empty string, if the passed string is null or empty
407+
*/
408+
public static String emptyIfNull(String str) {
409+
return isEmpty(str) ? "" : str;
410+
}
401411
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.cloudinary.api.signing;
2+
3+
import org.junit.Test;
4+
5+
import static org.junit.Assert.assertFalse;
6+
import static org.junit.Assert.assertTrue;
7+
8+
public class ApiResponseSignatureVerifierTest {
9+
@Test
10+
public void testVerifySignature() {
11+
ApiResponseSignatureVerifier verifier = new ApiResponseSignatureVerifier("X7qLTrsES31MzxxkxPPA-pAGGfU");
12+
13+
boolean actual = verifier.verifySignature("tests/logo.png", "1", "08d3107a5b2ad82e7d82c0b972218fbf20b5b1e0");
14+
15+
assertTrue(actual);
16+
}
17+
18+
@Test
19+
public void testVerifySignatureFail() {
20+
ApiResponseSignatureVerifier verifier = new ApiResponseSignatureVerifier("X7qLTrsES31MzxxkxPPA-pAGGfU");
21+
22+
boolean actual = verifier.verifySignature("tests/logo.png", "1", "doesNotMatchForSure");
23+
24+
assertFalse(actual);
25+
}
26+
}

0 commit comments

Comments
 (0)