Checklist app에 광고가 있는데, 마켓에 배포된 버전을 사용할 경우에는 개발자도 광고를 제거할 수 없다. 개발자는 인앱구매가 정책적으로 불가능하고, 특정 단말에서만 광고를 제거하는 기능이 없기때문이다. 이 앱의 독일어 번역을 도와주신 분이 "번역 도와줬는데 할인이나 Promotion key같은 거 없냐"고 물어봐서 일단 급하게 Promotion key를 등록해서 광고를 제거시키는 기능을 만들어 보았다.
동작 방식은
1. 앱은 디바이스ID와 입력 받은 프로모션Key를 가지고 서버에 요청한다.
2. 서버는 프로모션Key가 존재하는 지 보고, 디바이스ID로 Key가 이미 사용중인지를 체크한다. 사용중이지 않다면 디바이스ID를 RSA암호화해서 보내주고, 해당 포로모션Key에 디바이스ID를 저장한다.
3. 앱은 서버에서 암호화된 디바이스ID를 받으면 DB에 저장한다.
4. 암호화된 디바이스ID를 RSA복화화해서 폰의 디바이스ID와 일치하면 광고를 제거한다.
이런 방식의 장점은 프로모션KEY 1개당 하나의 단말에서만 유효하게 동작시킬 수가 있고, 디바이스ID의 암호화는 서버에서만 할 수 있기 때문에 앱이 리버스 엔지니어링되더라도 KEY-GEN같은 것을 만들 수가 없게된다. 앱을 디컴파일해서 인증코드 1줄을 지우고 다시 패키징한다면 이 모든게 의미 없기는 마찬가지다.
Client 코드
RSAHelper
package com.mdiwebma.base.helper; import java.security.KeyFactory; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.PublicKey; import java.security.SecureRandom; import java.security.spec.InvalidKeySpecException; import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.X509EncodedKeySpec; import javax.crypto.Cipher; /** * @author dj.kim */ public class RSAHelper { private static final String ALGORITHM = "RSA"; private static final String CIPHER_RSA = "RSA/ECB/PKCS1Padding"; private static final int KEY_SIZE_BIT = 1024; private PublicKey publicKey; private PrivateKey privateKey; public RSAHelper() { } public RSAHelper(byte[] bytePublicKey, byte[] bytePrivateKey) { if (bytePublicKey != null) { importPublicKey(bytePublicKey); } if (bytePrivateKey != null) { importPrivateKey(bytePrivateKey); } } public boolean generateKey() { boolean success = false; try { // for detail: http://docs.oracle.com/javase/tutorial/security/apisign/step2.html final KeyPairGenerator keyGen = KeyPairGenerator.getInstance(ALGORITHM); keyGen.initialize(KEY_SIZE_BIT, new SecureRandom()); final KeyPair key = keyGen.generateKeyPair(); publicKey = key.getPublic(); privateKey = key.getPrivate(); success = true; } catch (Exception e) { e.printStackTrace(); } return success; } public byte[] exportPublicKey() { if (publicKey != null) { return publicKey.getEncoded(); } else { return null; } } public boolean importPublicKey(byte[] bytePublicKey) { boolean success = false; try { if (bytePublicKey != null) { this.publicKey = KeyFactory.getInstance(ALGORITHM).generatePublic(new X509EncodedKeySpec(bytePublicKey)); } success = true; } catch (InvalidKeySpecException e) { e.printStackTrace(); } catch (NoSuchAlgorithmException e) { e.printStackTrace(); } return success; } public byte[] exportPrivateKey() { if (privateKey != null) { return privateKey.getEncoded(); } else { return null; } } public boolean importPrivateKey(byte[] bytePrivateKey) { boolean success = false; try { if (bytePrivateKey != null) { this.privateKey = KeyFactory.getInstance(ALGORITHM).generatePrivate(new PKCS8EncodedKeySpec(bytePrivateKey)); } success = true; } catch (InvalidKeySpecException e) { e.printStackTrace(); } catch (NoSuchAlgorithmException e) { e.printStackTrace(); } return success; } public boolean validPublicKey() { return this.publicKey != null; } public boolean validPrivateKey() { return this.privateKey != null; } public boolean validBothKey() { return validPublicKey() && validPrivateKey(); } public byte[] encrypt(byte[] plainData) { byte[] cipherData = null; try { if (publicKey != null && plainData != null) { final Cipher cipher = Cipher.getInstance(CIPHER_RSA); cipher.init(Cipher.ENCRYPT_MODE, publicKey); cipherData = cipher.doFinal(plainData); } } catch (Exception e) { e.printStackTrace(); } return cipherData; } public byte[] decrypt(byte[] cipherData) { byte[] decryptedData = null; try { if (privateKey != null && cipherData != null) { final Cipher cipher = Cipher.getInstance(CIPHER_RSA); cipher.init(Cipher.DECRYPT_MODE, privateKey); decryptedData = cipher.doFinal(cipherData); } } catch (Exception ex) { ex.printStackTrace(); } return decryptedData; } }
PromotionUtils
package com.mdiwebma.base.utils; import android.os.Build; import com.mdiwebma.base.ApplicationKeeper; import com.mdiwebma.base.BuildConfig; import com.mdiwebma.base.debug.LoggingClient; import com.mdiwebma.base.helper.RSAHelper; import com.mdiwebma.base.settings.SettingString; import com.mdiwebma.base.task.SimpleCallback; import com.mdiwebma.base.task.SimpleCallbackUiThread; import org.json.JSONException; import org.json.JSONObject; import java.io.UnsupportedEncodingException; import java.security.InvalidParameterException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Arrays; public class PromotionUtils { private static final SettingString removeAdsPromotion = new SettingString("removeAdsPromotion", ""); private static final String PROMOTION_VERSION = "1"; private static byte[] v1_privateKey = ; //byte[] v1_publicKey = ; public static boolean hasRemoveAdsPromotionKey() { return hasRemoveAdsPromotion(removeAdsPromotion.getValue()); } public static boolean hasRemoveAdsPromotion(String jsonResponse) { try { JSONObject jsonObject = new JSONObject(jsonResponse); JSONObject resultObject = jsonObject.getJSONObject("result"); if ("1".equals(resultObject.getString("version"))) { return hasRemoveAdsPromotion_v1(resultObject.getString("data")); } } catch (Exception ex) { if (BuildConfig.DEBUG) { ex.printStackTrace(); } } return false; } public static boolean hasRemoveAdsPromotion_v1(String hexEncryptedDeviceId) { try { byte[] encryptedDeviceIdBytes = hexStringToByte(hexEncryptedDeviceId); RSAHelper rsaHelper = new RSAHelper(); rsaHelper.importPrivateKey(v1_privateKey); byte[] decryptedDeviceId = rsaHelper.decrypt(encryptedDeviceIdBytes); byte[] deviceId = getMd5Result(getDeviceId()); return Arrays.equals(decryptedDeviceId, deviceId); } catch (Exception ex) { if (BuildConfig.DEBUG) { ex.printStackTrace(); } } return false; } public static void requestPromotionKey(String promotionKey, final SimpleCallbackUiThreadcallback) { LoggingClient.getInstance().requestPromotionKey(PROMOTION_VERSION, promotionKey, getPromotionDeviceId(), new SimpleCallback () { @Override public void onSucceeded(String result) { try { if (hasRemoveAdsPromotion(result)) { removeAdsPromotion.setValue(result); callback.onSucceeded(result); } else { JSONObject jsonObject = new JSONObject(result); callback.onFailed(new ErrorCodeException(jsonObject.optString("message"), jsonObject.optInt("code"))); } } catch (JSONException ex) { callback.onFailed(ex); } } @Override public void onFailed(Exception ex) { callback.onFailed(ex); } }); } public static String getPromotionDeviceId() { return byteToHexString(getMd5Result(getDeviceId())); } public static String getDeviceId() { return ApplicationKeeper.get().getPackageName() + ":" + Build.SERIAL + ":" + android.provider.Settings.Secure.getString(ApplicationKeeper.get().getContentResolver(), android.provider.Settings.Secure.ANDROID_ID); } public static byte[] getMd5Result(String data) { try { MessageDigest md = MessageDigest.getInstance("MD5"); return md.digest(data.getBytes("UTF-8")); } catch (NoSuchAlgorithmException ex) { return null; } catch (UnsupportedEncodingException ex) { return null; } } public static String byteToHexString(byte[] data) { StringBuilder sb = new StringBuilder(); for (byte b : data) { sb.append(String.format("%02X", b)); } return sb.toString(); } public static byte[] hexStringToByte(String hexString) { if (hexString == null || hexString.length() % 2 != 0) { throw new InvalidParameterException("invalid hex string"); } int len = hexString.length(); if (len == 0) { return new byte[]{}; } byte[] data = new byte[len / 2]; for (int i = 0; i < len; i += 2) { data[i / 2] = (byte) ((Character.digit(hexString.charAt(i), 16) << 4) + Character.digit(hexString.charAt(i + 1), 16)); } return data; } }
서버쪽 코드
PromotionUtils
public class PromotionUtils { //byte[] privateKey = ; private static final byte[] v1_publicKey = ; public static String getEncryptedDeviceId_v1(String hexDeviceId) throws Exception { byte[] deviceIdByte = hexStringToByte(hexDeviceId); RSAHelper rsaHelper = new RSAHelper(); rsaHelper.importPublicKey(v1_publicKey); byte[] encryptedDeviceIdBytes = rsaUtils.encrypt(deviceIdByte); return byteToHexString(encryptedDeviceIdBytes); } }
BackEndBO (Google app engin)
,
public void requestPromotion(HttpServletRequest req, HttpServletResponse resp) throws IOException { try { String requestId = req.getParameter(Const.requestId); String appId = req.getParameter(Const.appId); String appVersion = req.getParameter(Const.appVersion); String osVersion = req.getParameter(Const.osVersion); String securityKey = req.getParameter(Const.securityKey); String hmac = getHMAC(requestId, appId, appVersion, osVersion); if (!hmac.equals(securityKey)) { resp.getWriter().println("{code:2, message:\"securityKey failed\"}"); return; } String promotionKey = req.getParameter(Const.promotionKey); String deviceId = req.getParameter(Const.deviceId); if (StringUtil.isEmptyOrWhitespace(promotionKey) || StringUtil.isEmptyOrWhitespace(deviceId)) { resp.getWriter().println("{code:3, message:\"invalid parameter\"}"); return; } String promotionVersion = req.getParameter(Const.promotionVersion); if (!"1" .equals(promotionVersion)) { resp.getWriter().println("{code:4, message:\"promotion version not supported\"}"); return; } Query.Filter propertyFilter1 = new Query.FilterPredicate(Const.appId, Query.FilterOperator.EQUAL, appId); Query.Filter propertyFilter2 = new Query.FilterPredicate(Const.promotionKey, Query.FilterOperator.EQUAL, promotionKey); Query.Filter propertyFilter = new Query.CompositeFilter(Query.CompositeFilterOperator.AND, Arrays.asList(propertyFilter1, propertyFilter2)); Query query = new Query(Const.PromotionEntity).setFilter(propertyFilter); Entity entity = datastore.prepare(query).asSingleEntity(); if (entity != null) { String entityDeviceId = (String) entity.getProperty(Const.deviceId); if (entityDeviceId == null || entityDeviceId.length() == 0 || entityDeviceId.equals(deviceId)) { try { String data = PromotionUtils.getEncryptedDeviceId_v1(deviceId); if (entityDeviceId == null || entityDeviceId.length() == 0) { entity.setProperty(Const.deviceId, deviceId); entity.setProperty(Const.promotionVersion, promotionVersion); datastore.put(entity); } JsonObject jsonObject = new JsonObject(); jsonObject.addProperty("code", 0); jsonObject.addProperty("message", "success"); JsonObject resultObject = new JsonObject(); resultObject.addProperty("version", promotionVersion); resultObject.addProperty("data", data); jsonObject.add("result", resultObject); resp.getWriter().println(jsonObject.toString()); } catch (Exception ex) { resp.getWriter().println("{code:6, message:\"" + ex.getMessage() + "\"}"); } } else { resp.getWriter().println("{code:5, message:\"promotion key already used\"}"); } } else { resp.getWriter().println("{code:1, message:\"promotion key not found\"}"); } } catch (Exception ex) { resp.getWriter().println("{code:500, message:\"" + ex.toString() + "\"}"); } }