package org.commcare.utils;
import android.util.Pair;
import org.spongycastle.jce.provider.BouncyCastleProvider;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.UnsupportedEncodingException;
import java.net.MalformedURLException;
import java.net.URL;
import java.security.InvalidKeyException;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.Signature;
import java.security.SignatureException;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.X509EncodedKeySpec;
import java.util.regex.Pattern;
/**
* A set of helper methods for verifying whether a message was genuinely sent from HQ. Currently we
* expcect the SMS in the format [commcare app - do not delete] link where the link resolves to
* some string such as
*
* Y2NhcHA6IGh0dHA6Ly9iaXQubHkvMU5JMUl6MyBzaWduYXR1cmU6IEvECygFUhiUH
* 3TRjC0lClQrpLR7lG//IpDYpRH7ComtZRjTirteXmPyM9fRgbPZ9K6jG9zEms9WQj55Uo7jTujKNYThjU8rJJmWLouJBr/Yn
* WobEupwzn6DP2FavPF1YLPbp0ZctOfymW3m4j3VZ0lR2dMOjmInMSBiInqICKid
*
* Which is base 64 decoded decoded into:
*
* ccapp: <profile link> signature: <binary signature>
*
* And we can then verify that the profile link was in fact signed (using SHA256withRSA) by
* the CommCareHQ private key
*
* @author Will Pride (wpride@dimagi.com)
*/
public class SigningUtil {
private final static Pattern WHITELISTED_URL_HOSTS_REGEX =
Pattern.compile("\\.commcarehq\\.org$");
/**
* Given a trimmed byte[] payload, return the parsed out download link and signature
*
* @return Pair of <Download Link, Signature>
* @throws Exception Throw a generic exception if we fail during signature parse/verification
*/
public static Pair<String, byte[]> getUrlAndSignatureFromPayload(byte[] payload) throws Exception {
byte[] signatureBytes = getSignatureBytes(payload);
byte[] messageBytes = getMessageBytes(payload);
String downloadLink = getDownloadLink(messageBytes);
return new Pair<>(downloadLink, signatureBytes);
}
/**
* Given a base64 encoded URL, decode the URL, and return first line read
* from accessing that URL
*/
public static String convertEncodedUrlToPayload(String baseEncodedUrl)
throws IOException, Base64DecoderException {
return readURL(decodeUrl(baseEncodedUrl));
}
protected static String decodeUrl(String baseEncodedUrl)
throws Base64DecoderException, UnsupportedEncodingException {
String decodedUrl;
if (baseEncodedUrl.startsWith("http://") || baseEncodedUrl.startsWith("https://")) {
// for backwards compatibility, accept non-base64 encoded URLS
// once all users have migrated to the new format
// (info available on HQ?) we can remove this branch
decodedUrl = baseEncodedUrl;
} else {
decodedUrl = new String(Base64.decode(baseEncodedUrl), "UTF-8");
}
assertWhitelistedUrlHost(decodedUrl);
return decodedUrl;
}
/**
* Very basic method to prevent spoofed SMSs from making CommCare hit malicious URLs.
*/
private static void assertWhitelistedUrlHost(String urlString) {
URL url;
try {
url = new URL(urlString);
} catch (MalformedURLException e) {
throw new RuntimeException(urlString + " is not a valid URL.");
}
String host = url.getHost();
if (!WHITELISTED_URL_HOSTS_REGEX.matcher(host).find()) {
throw new DisallowedSMSInstallURLException(url + " is not an approved URL.");
}
}
// given the raw trimmed byte paylaod, return the message (everything before the signature)
private static byte[] getMessageBytes(byte[] payload) {
byte[] messageBytes = new byte[getSignatureStartIndex(payload)];
for (int i = 0; i < getSignatureStartIndex(payload); i++) {
messageBytes[i] = payload[i];
}
return messageBytes;
}
/**
* Given the link and signature, verify the link using the public key
*
* @param message the download link
* @param signature the signature bytes
* @return valid download link if verified, null if not verified
* @throws SignatureException if we have an internal error during verification
*/
public static String verifyMessageAndBytes(String message, byte[] signature) throws Exception {
String keyString = GlobalConstants.TRUSTED_SOURCE_PUBLIC_KEY;
boolean success = verifyMessageSignatureHelper(keyString, message, signature);
if (success) {
return message;
}
return null;
}
/**
* Given the raw message bytes not including the signature, convert to UTF-8 and parse out
* the download link
*
* @param messageBytes the raw bytes of the message payload (not the signature)
* @return the parsed out profile link
*/
private static String getDownloadLink(byte[] messageBytes) throws Exception {
String textMessage = new String(messageBytes, "UTF-8");
return textMessage.substring(textMessage.indexOf("ccapp: ") + "ccapp: ".length(),
textMessage.indexOf("signature") - 1);
}
/**
* Get the byte representation of the signature from the plaintext. We have to pull this out
* directly because the conversion from Base64 can have a non-1:1 correspondence with the actual
* bytes
*
* @return the binary representation of the signtature
*/
private static byte[] getSignatureBytes(byte[] messageBytes) {
int lastSpaceIndex = getSignatureStartIndex(messageBytes);
int signatureByteLength = messageBytes.length - lastSpaceIndex;
byte[] signatureBytes = new byte[signatureByteLength];
for (int i = 0; i < signatureByteLength; i++) {
signatureBytes[i] = messageBytes[i + lastSpaceIndex];
}
return signatureBytes;
}
/**
* Iterate through the byte array until we find the third "space" character (represented
* by integer 32) and then return its index
*
* @param messageBytes the raw bytes of the Base64 message
* @return index of the third "space" byte, -1 if none encountered
*/
private static int getSignatureStartIndex(byte[] messageBytes) {
int index = 0;
int spaceCount = 0;
int spaceByte = 32;
for (byte b : messageBytes) {
if (b == spaceByte) {
if (spaceCount == 2) {
return index + 1;
} else {
spaceCount++;
}
}
index++;
}
return -1;
}
// given a text message, return the raw Base64 bytes
public static byte[] getBytesFromString(String stringMessage) throws Exception {
return Base64.decode(stringMessage);
}
// given a text message, trim out the [commcare app - do not delete] and return
public static String trimMessagePayload(String newMessage) {
return newMessage.substring(newMessage.indexOf(GlobalConstants.SMS_INSTALL_KEY_STRING) +
GlobalConstants.SMS_INSTALL_KEY_STRING.length() + 1);
}
/**
* @param publicKeyString the known public key of CCHQ
* @param message the message content
* @param messageSignature the signature generated by HQ with its private key and the message content
* @return whether or not the message was verified to be sent with HQ's private key
*/
private static boolean verifyMessageSignatureHelper(String publicKeyString, String message, byte[] messageSignature) throws Base64DecoderException, NoSuchAlgorithmException, InvalidKeySpecException, SignatureException, InvalidKeyException {
PublicKey publicKey = getPublicKey(publicKeyString);
return verifyMessageSignature(publicKey, message, messageSignature);
}
// convert from a key string to a PublicKey object
private static PublicKey getPublicKey(String key)
throws Base64DecoderException, NoSuchAlgorithmException, InvalidKeySpecException {
byte[] derPublicKey = Base64.decode(key);
X509EncodedKeySpec spec = new X509EncodedKeySpec(derPublicKey);
KeyFactory kf = KeyFactory.getInstance("RSA");
return kf.generatePublic(spec);
}
private static boolean verifyMessageSignature(PublicKey publicKey,
String messageString, byte[] signature)
throws SignatureException, NoSuchAlgorithmException, InvalidKeyException {
Signature sign = Signature.getInstance("SHA256withRSA/PSS", new BouncyCastleProvider());
byte[] message = messageString.getBytes();
sign.initVerify(publicKey);
sign.update(message);
return sign.verify(signature);
}
/**
* Read the data from the URL arg and return as a string (only return first line)
*/
private static String readURL(String url) throws IOException {
String acc = "";
URL oracle = new URL(url);
BufferedReader in = new BufferedReader(
new InputStreamReader(oracle.openStream()));
String inputLine;
// only return the first line
if ((inputLine = in.readLine()) != null)
acc = inputLine;
in.close();
return acc;
}
public static class DisallowedSMSInstallURLException extends RuntimeException {
public DisallowedSMSInstallURLException(String message) {
super(message);
}
}
}