车载码初步实现
diff --git a/payapi/build.gradle b/payapi/build.gradle
index 76f80e3..0a7d16c 100644
--- a/payapi/build.gradle
+++ b/payapi/build.gradle
@@ -104,6 +104,7 @@
implementation 'log4j:log4j:1.2.17'
implementation 'com.alibaba:fastjson:1.2.60'
+ implementation 'com.eatthepath:java-otp:0.1.0'
implementation project(':payapi-common')
/*支付宝SDK*/
implementation group: 'com.alipay.sdk', name: 'alipay-sdk-java', version: '3.7.110.ALL'
diff --git a/payapi/src/main/java/com/supwisdom/dlpay/busQRcode/BinUtil.java b/payapi/src/main/java/com/supwisdom/dlpay/busQRcode/BinUtil.java
new file mode 100644
index 0000000..5b8f43b
--- /dev/null
+++ b/payapi/src/main/java/com/supwisdom/dlpay/busQRcode/BinUtil.java
@@ -0,0 +1,67 @@
+package com.supwisdom.dlpay.busQRcode;
+
+import org.apache.commons.codec.binary.Base64;
+
+public class BinUtil {
+ public static byte[] decodeHex(String data) {
+ if (data == null || data.length() % 2 != 0) {
+ throw new RuntimeException("decodeHex data length must be divided by 2");
+ }
+ byte[] result = new byte[data.length() / 2];
+ for (int i = 0; i < data.length(); i += 2) {
+ result[i / 2] = (byte) Integer.parseInt(data.substring(i, i + 2), 16);
+ }
+ return result;
+ }
+
+ public static String encodeHex(byte[] data) {
+ if (data == null) {
+ throw new RuntimeException("encodeHex data must be not null");
+ }
+ StringBuilder sb = new StringBuilder();
+ for (byte datum : data) {
+ sb.append(String.format("%02x", datum & 0xFF));
+ }
+ return sb.toString();
+ }
+
+ public static byte[] encodeBCD(String data) {
+ if (data == null || data.length() % 2 != 0) {
+ throw new RuntimeException("encodeBCD data length must be divided by 2");
+ }
+ byte[] result = new byte[data.length() / 2];
+ int base = (int)'0';
+ for (int i = 0; i < data.length(); i += 2) {
+ int t = (int)data.charAt(i) - base;
+ int t1 = (int)data.charAt(i + 1) - base;
+ if (t < 0 || t > 9 || t1 < 0 || t1 > 9) {
+ throw new RuntimeException("encodeBCD char must be '0'~'9'");
+ }
+ result[i / 2] = (byte) (((t << 4) | t1) & 0xFF);
+ }
+ return result;
+ }
+
+ public static String decodeBCD(byte[] data) {
+ if (data == null) {
+ throw new RuntimeException("decodeBCD data must be not null");
+ }
+ int base = (int)'0';
+ StringBuilder sb = new StringBuilder();
+ for (byte datum : data) {
+ int t1 = ((datum >> 4) & 0x0F) + base;
+ int t2 = (datum & 0x0F) + base;
+ sb.append((char) t1)
+ .append((char) t2);
+ }
+ return sb.toString();
+ }
+
+ public static String encodeBase64(byte[] data) {
+ return Base64.encodeBase64String(data);
+ }
+
+ public static byte[] decodeBase64(String data) {
+ return Base64.decodeBase64(data);
+ }
+}
diff --git a/payapi/src/main/java/com/supwisdom/dlpay/busQRcode/CryptoUtil.java b/payapi/src/main/java/com/supwisdom/dlpay/busQRcode/CryptoUtil.java
new file mode 100644
index 0000000..75c4d08
--- /dev/null
+++ b/payapi/src/main/java/com/supwisdom/dlpay/busQRcode/CryptoUtil.java
@@ -0,0 +1,117 @@
+package com.supwisdom.dlpay.busQRcode;
+
+import javax.crypto.*;
+import javax.crypto.spec.DESKeySpec;
+import javax.crypto.spec.DESedeKeySpec;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import java.security.InvalidAlgorithmParameterException;
+import java.security.InvalidKeyException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.spec.InvalidKeySpecException;
+import java.security.spec.KeySpec;
+import java.util.Arrays;
+
+public class CryptoUtil {
+
+ private static byte[] doCipher(Cipher cipher, SecretKey key, int mode, byte[] data) {
+ try {
+ cipher.init(mode, key);
+ return cipher.doFinal(data);
+ } catch (InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
+ e.printStackTrace();
+ }
+ return null;
+ }
+
+ private static byte[] doDesCipher(byte[] key, int cipherMode, byte[] data) {
+ try {
+ String mode;
+ KeySpec keySpec;
+ if (key.length == 8) {
+ mode = "DES";
+ keySpec = new DESKeySpec(key);
+ } else if (key.length == 16) {
+ mode = "DESede";
+ byte[] keyEDE = new byte[24];
+ System.arraycopy(key, 0, keyEDE, 0, 16);
+ System.arraycopy(key, 0, keyEDE, 16, 8);
+ keySpec = new DESedeKeySpec(keyEDE);
+ } else {
+ throw new RuntimeException("DES Key length must be 8 or 16 bytes");
+ }
+ Cipher keyCipher = Cipher.getInstance(mode + "/ECB/NoPadding");
+ SecretKey secret = SecretKeyFactory.getInstance(mode).generateSecret(keySpec);
+ return doCipher(keyCipher, secret, cipherMode, data);
+ } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeySpecException | InvalidKeyException e) {
+ e.printStackTrace();
+ }
+ return null;
+ }
+
+ public static byte[] desEncryptECB(byte[] key, byte[] plain) {
+ return doDesCipher(key, Cipher.ENCRYPT_MODE, plain);
+ }
+
+ public static byte[] desDecryptECB(byte[] key, byte[] cipher) {
+ return doDesCipher(key, Cipher.DECRYPT_MODE, cipher);
+ }
+
+ private static byte[] messageDigest(String alg, byte[] data) {
+ try {
+ MessageDigest md = MessageDigest.getInstance(alg);
+ md.update(data);
+ return md.digest();
+ } catch (NoSuchAlgorithmException e) {
+ e.printStackTrace();
+ }
+ return null;
+ }
+
+ public static byte[] md5(byte[] data) {
+ return messageDigest("MD5", data);
+ }
+
+ public static byte[] sha256(byte[] data) {
+ return messageDigest("SHA-256", data);
+ }
+
+ public static byte[] sha1(byte[] data) {
+ return messageDigest("SHA-1", data);
+ }
+
+ private static byte[] doAESCipher(byte[] key, byte[] iv, byte[] data, int mode) {
+ try {
+ Cipher keyCipher = Cipher.getInstance("AES/CFB/NoPadding");
+ IvParameterSpec ivSpec = new IvParameterSpec(iv);
+ SecretKeySpec secret = new SecretKeySpec(key, "AES");
+ keyCipher.init(mode, secret, ivSpec);
+ return keyCipher.doFinal(data);
+ } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException
+ | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
+ e.printStackTrace();
+ }
+ return null;
+ }
+
+ public static byte[] aesEncryptCFB(byte[] key, byte[] plain) {
+ byte[] iv = new byte[16];
+ Arrays.fill(iv, (byte) 0);
+ return aesEncryptCFB(key, plain, iv);
+ }
+
+ public static byte[] aesEncryptCFB(byte[] key, byte[] plain, byte[] iv) {
+ return doAESCipher(key, iv, plain, Cipher.ENCRYPT_MODE);
+ }
+
+ public static byte[] aesDecryptCFB(byte[] key, byte[] cipher, byte[] iv) {
+ return doAESCipher(key, iv, cipher, Cipher.DECRYPT_MODE);
+ }
+
+ public static byte[] aesDecryptCFB(byte[] key, byte[] cipher) {
+ byte[] iv = new byte[16];
+ Arrays.fill(iv, (byte) 0);
+ return aesDecryptCFB(key, cipher, iv);
+ }
+}
diff --git a/payapi/src/main/java/com/supwisdom/dlpay/busQRcode/PbocAlgorithem.java b/payapi/src/main/java/com/supwisdom/dlpay/busQRcode/PbocAlgorithem.java
new file mode 100644
index 0000000..f63b4f1
--- /dev/null
+++ b/payapi/src/main/java/com/supwisdom/dlpay/busQRcode/PbocAlgorithem.java
@@ -0,0 +1,93 @@
+package com.supwisdom.dlpay.busQRcode;
+
+import java.util.Arrays;
+
+import static com.supwisdom.dlpay.busQRcode.CryptoUtil.desDecryptECB;
+import static com.supwisdom.dlpay.busQRcode.CryptoUtil.desEncryptECB;
+
+
+public class PbocAlgorithem {
+ private static final byte[] PADDING = new byte[]{(byte) 0x80, 0, 0, 0, 0, 0, 0, 0};
+
+ private static byte[] doDeliveryKey(byte[] key, byte[] factor) {
+ if (key.length == 16) {
+ byte[] factor1 = new byte[8];
+ for (int i = 0; i < 8; i++) {
+ factor1[i] = (byte) ((~((int) factor[i] & 0xFF)) & 0xFF);
+ }
+ byte[] d1 = desEncryptECB(key, factor);
+ byte[] d2 = desEncryptECB(key, factor1);
+ byte[] result = new byte[16];
+ System.arraycopy(d1, 0, result, 0, 8);
+ System.arraycopy(d2, 0, result, 8, 8);
+ return result;
+ } else if (key.length == 8) {
+ return desEncryptECB(key, factor);
+ } else {
+ throw new RuntimeException("Des key length must be 8 or 16");
+ }
+ }
+
+ public static byte[] deliveryKey(byte[] rootKey, byte[] factory) {
+ if (factory.length % 8 != 0) {
+ throw new RuntimeException("delivery key factory length must be divived by 8");
+ }
+ if (factory.length / 8 > 3) {
+ throw new RuntimeException("delivery key factor length must be less or equal 24");
+ }
+
+ byte[] deliveryKey = rootKey;
+
+ for (int i = 0; i < factory.length; i += 8) {
+ deliveryKey = doDeliveryKey(deliveryKey, Arrays.copyOfRange(factory, i, i + 8));
+ }
+ return deliveryKey;
+ }
+
+ private static byte[] doDesMac(byte[] key, byte[] init, byte[] data) {
+ byte[] temp = Arrays.copyOf(init, 8);
+ for (int offset = 0; offset < data.length; offset += 8) {
+ int i;
+ for (i = 0; i < 8 && i + offset < data.length; ++i) {
+ temp[i] = (byte) (((int) temp[i] & 0xFF) ^ ((int) data[i + offset] & 0xFF));
+ }
+ for (int j = i; i < 8; ++i) {
+ temp[i] = (byte) (((int) temp[i] & 0xFF) ^ ((int) PADDING[i - j] & 0xFF));
+ }
+ temp = desEncryptECB(key, temp);
+ }
+ return temp;
+ }
+
+ public static byte[] desMac(byte[] key, byte[] init, byte[] data) {
+ byte[] buffer = Arrays.copyOfRange(doDesMac(key, init, data), 0, 4);
+ if (data.length % 8 == 0) {
+ buffer = doDesMac(key, buffer, PADDING);
+ }
+ return Arrays.copyOfRange(buffer, 0, 4);
+ }
+
+ public static byte[] desMac(byte[] key, byte[] data) {
+ byte[] init = new byte[8];
+ Arrays.fill(init, (byte) 0);
+ return desMac(key, init, data);
+ }
+
+ public static byte[] des3Mac(byte[] key, byte[] init, byte[] data) {
+ byte[] k1 = Arrays.copyOfRange(key, 0, 8);
+ byte[] k2 = Arrays.copyOfRange(key, 8, 16);
+ byte[] buffer = doDesMac(k1, init, data);
+ if (data.length % 8 == 0) {
+ buffer = doDesMac(key, buffer, PADDING);
+ }
+ buffer = desDecryptECB(k2, buffer);
+ buffer = desEncryptECB(k1, buffer);
+ return Arrays.copyOfRange(buffer, 0, 4);
+ }
+
+ public static byte[] des3Mac(byte[] key, byte[] data) {
+ byte[] init = new byte[8];
+ Arrays.fill(init, (byte) 0);
+ return des3Mac(key, init, data);
+ }
+}
diff --git a/payapi/src/main/java/com/supwisdom/dlpay/busQRcode/QrCode.java b/payapi/src/main/java/com/supwisdom/dlpay/busQRcode/QrCode.java
new file mode 100644
index 0000000..35a50e0
--- /dev/null
+++ b/payapi/src/main/java/com/supwisdom/dlpay/busQRcode/QrCode.java
@@ -0,0 +1,280 @@
+package com.supwisdom.dlpay.busQRcode;
+
+import com.eatthepath.otp.TimeBasedOneTimePasswordGenerator;
+import com.supwisdom.dlpay.util.TOTP;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.crypto.Cipher;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+
+import static com.supwisdom.dlpay.busQRcode.BinUtil.*;
+import static com.supwisdom.dlpay.busQRcode.CryptoUtil.*;
+
+public class QrCode {
+ private static Logger logger = LoggerFactory.getLogger(QrCode.class);
+
+ public static final String DELIMITER = ":";
+ public static final String SCOPE_DELIMITER = ",";
+ public static final String DEFAULT_SCOPE = "1"; //默认仅支持 1-大理车载消费场景
+ public static final int TOTP_OFFSET = 30;
+
+// static final ArrayList<String> DEFAULT_SCOPES = new ArrayList<>();
+// static {
+// DEFAULT_SCOPES.add("1"); //默认仅支持 1-大理车载消费场景
+// }
+
+ private Builder qrBuilder;
+
+ private QrCode(Builder b) {
+ this.qrBuilder = b;
+ }
+
+ public static class Builder {
+ private String appid;
+ private String uid;
+ private String scope;
+ private String seed = "a8aae1a41ffab089428e8e21381cc34af3b614ac7b7cba50f58470b8e50c6072";
+ private boolean resetSeed = false;
+ private byte[] iv = decodeHex("55b6f5b3287c535f8274a99354676d0b");
+ private boolean resetIv = false;
+ private byte[] rootKey;
+ private byte[] sKey;
+ private byte[] des3Key;
+ private String prefix;
+ private boolean debug;
+
+ public QrCode create() {
+ return new QrCode(this);
+ }
+
+ public Builder appid(String appid) {
+ if (StringUtils.isBlank(appid) || !StringUtils.isAsciiPrintable(appid)) {
+ throw new RuntimeException("appid is invalid");
+ }
+ this.appid = appid;
+ this.des3Key = md5(appid.getBytes());
+ return this;
+ }
+
+ public Builder uid(String uid) {
+ if (StringUtils.isBlank(uid) || !StringUtils.isAsciiPrintable(uid)) {
+ throw new RuntimeException("uid is invalid");
+ }
+ this.uid = uid;
+ return this;
+ }
+
+ public Builder scope(String scope) {
+ if (StringUtils.isBlank(scope)) {
+ throw new RuntimeException("scope is empty");
+ }
+ this.scope = scope;
+ return this;
+ }
+
+ public Builder rootkey(String key) {
+ this.rootKey = decodeHex(key);
+ return this;
+ }
+
+ public Builder rootkey(String appid, String skey){
+ if (StringUtils.isBlank(appid) || !StringUtils.isAsciiPrintable(appid)) {
+ throw new RuntimeException("appid is invalid");
+ }
+ if (StringUtils.isBlank(skey)) {
+ throw new RuntimeException("sKey is empty");
+ }
+ this.appid = appid;
+ this.sKey = decodeHex(skey);
+ this.des3Key = md5(appid.getBytes());
+ this.rootKey = desEncryptECB(this.des3Key, this.sKey);
+ return this;
+ }
+
+ public Builder seed(String s) {
+ if (StringUtils.isBlank(s)) {
+ throw new RuntimeException("seed is empty");
+ }
+ this.seed = s;
+ this.resetSeed = true;
+ return this;
+ }
+
+ public Builder ivHex(String v) {
+ this.iv = decodeHex(v);
+ this.resetIv = true;
+ return this;
+ }
+
+ public Builder prefix(String p) {
+ if (p == null) {
+ throw new RuntimeException("prefix must not be null");
+ }
+ this.prefix = p;
+ return this;
+ }
+
+ public Builder debug(boolean on) {
+ this.debug = on;
+ return this;
+ }
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ private static String getRandomString(int length) {
+ String base = "abcdefghijklmnopqrstuvwxyz234567";
+ Random random = new Random(System.currentTimeMillis());
+ StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < length; i++) {
+ int number = random.nextInt(base.length());
+ sb.append(base.charAt(number));
+ }
+ return sb.toString();
+ }
+
+ private String genTOTPWithSeed(String seed, int pl) {
+ final TimeBasedOneTimePasswordGenerator alg = totpGenerator(pl);
+ final SecretKey secretKey = new SecretKeySpec(decodeHex(seed),
+ TimeBasedOneTimePasswordGenerator.TOTP_ALGORITHM_HMAC_SHA256);
+ try {
+ Date now = Calendar.getInstance().getTime();
+ return StringUtils.leftPad(String.format("%d", alg.generateOneTimePassword(secretKey, now)),
+ pl, '0');
+ } catch (InvalidKeyException e) {
+ throw new RuntimeException("TOTP Error", e);
+ }
+ }
+
+ private TimeBasedOneTimePasswordGenerator totpGenerator(int passwordLength) {
+ try {
+ return new TimeBasedOneTimePasswordGenerator(30, TimeUnit.SECONDS, passwordLength,
+ TimeBasedOneTimePasswordGenerator.TOTP_ALGORITHM_HMAC_SHA256);
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException("TOTP Error", e);
+ }
+ }
+
+ private boolean checkScope(List<String> scopes) {
+ return scopes.contains(DEFAULT_SCOPE);
+ }
+
+ private boolean verifyTotp(String totp, String secret, int offset) {
+ String second = "30";
+ String seed = secret;
+ long T0 = 0;
+ String[] keys = new String[offset * 2 + 1];
+ long time = Calendar.getInstance().getTime().getTime() / 1000;
+ for (int i = 0; i < keys.length; i++) {
+ String steps = "0";
+ try {
+ long T = (time - T0) / Long.parseLong(second);
+ steps = Long.toHexString(T + (i - offset)).toUpperCase();
+ while (steps.length() < 16) {
+ steps = "0" + steps;
+ }
+ String key = TOTP.generateTOTP(secret, steps, "8", TimeBasedOneTimePasswordGenerator.TOTP_ALGORITHM_HMAC_SHA256);
+ keys[i] = key;
+ } catch (final Exception e) {
+ System.out.println("Error : " + e);
+ }
+ }
+ for (String key : keys) {
+ if (key.equals(totp)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public String qrcode() {
+ if (qrBuilder.debug) {
+ logger.info("appid=" + qrBuilder.appid);
+ logger.info("uid=" + qrBuilder.uid);
+ logger.info("scope=" + qrBuilder.scope);
+ if (qrBuilder.resetSeed) logger.info("seed=" + qrBuilder.seed);
+ if (qrBuilder.resetIv) logger.info("iv=" + encodeHex(qrBuilder.iv));
+ if (null != qrBuilder.sKey) logger.info("sKey=" + encodeHex(qrBuilder.sKey));
+ if (null != qrBuilder.prefix) logger.info("prefix=" + qrBuilder.prefix);
+
+ logger.info("rootkey=" + encodeHex(qrBuilder.rootKey));
+ logger.info("=======================================================");
+ }
+
+ final String totp = genTOTPWithSeed(qrBuilder.seed, 8);
+ final String encDataPlain = new StringJoin(DELIMITER)
+ .add(qrBuilder.uid)
+ .add(qrBuilder.scope)
+ .add(totp).toString();
+
+ final byte[] encData = aesEncryptCFB(qrBuilder.rootKey, encDataPlain.getBytes(), qrBuilder.iv);
+ final String code = encodeBase64(encData);
+ if (qrBuilder.debug) {
+ logger.info("encdata plain= " + encDataPlain);
+ }
+
+ if (qrBuilder.prefix != null) {
+ return qrBuilder.prefix + code;
+ }
+ return code;
+ }
+
+ public String decodeUid(String qrcode) {
+ if (qrBuilder.debug) {
+ logger.info("appid=" + qrBuilder.appid);
+ if (qrBuilder.resetSeed) logger.info("seed=" + qrBuilder.seed);
+ if (qrBuilder.resetIv) logger.info("iv=" + encodeHex(qrBuilder.iv));
+ if (null != qrBuilder.sKey) logger.info("sKey=" + encodeHex(qrBuilder.sKey));
+ if (null != qrBuilder.prefix) logger.info("prefix=" + qrBuilder.prefix);
+ logger.info("=======================================================");
+ }
+ if (null == qrBuilder.rootKey) {
+ throw new RuntimeException("qr decode root key must not be null");
+ }
+ if (qrBuilder.prefix != null) {
+ qrcode = qrcode.substring(qrBuilder.prefix.length());
+ }
+ final String encDataPlain = new String(aesDecryptCFB(qrBuilder.rootKey, decodeBase64(qrcode), qrBuilder.iv));
+ if (qrBuilder.debug) {
+ logger.info("Decode data : <" + encDataPlain + ">");
+ }
+ String[] fields = encDataPlain.split(DELIMITER);
+ if (fields.length < 3) {
+ throw new RuntimeException("qrcode plain text format error!");
+ }
+ String uid = fields[0];
+ String[] scopes = fields[1].split(SCOPE_DELIMITER);
+ String totp = fields[2];
+
+ if (!verifyTotp(totp, qrBuilder.seed, TOTP_OFFSET)) {
+ throw new RuntimeException("qrcode is invalid!"); //TOTP校验
+ }
+
+ if (!checkScope(Arrays.asList(scopes))) {
+ throw new RuntimeException("qrcode is not supported!"); //应用场景校验
+ }
+
+ return uid;
+ }
+
+ public String tac(String uid, Integer amount, String transdate, String transtime) {
+ if (null == qrBuilder.appid) {
+ throw new RuntimeException("appid is null");
+ }
+ if (null == qrBuilder.sKey) {
+ throw new RuntimeException("sKey is null");
+ }
+ final String encdata = uid + qrBuilder.appid + amount + transdate + transtime + "{" + encodeHex(qrBuilder.sKey) + "}";
+ return encodeHex(sha256(encdata.getBytes())).toUpperCase();
+ }
+
+}
diff --git a/payapi/src/main/java/com/supwisdom/dlpay/busQRcode/StringJoin.java b/payapi/src/main/java/com/supwisdom/dlpay/busQRcode/StringJoin.java
new file mode 100644
index 0000000..fdc5ca8
--- /dev/null
+++ b/payapi/src/main/java/com/supwisdom/dlpay/busQRcode/StringJoin.java
@@ -0,0 +1,30 @@
+package com.supwisdom.dlpay.busQRcode;
+
+public class StringJoin {
+ private String delimiter;
+ private StringBuilder builder;
+
+ public StringJoin(String delimiter) {
+ if(delimiter == null) {
+ throw new RuntimeException("Delimiter must not be null");
+ }
+ this.delimiter = delimiter;
+ builder = new StringBuilder();
+ }
+
+ public StringJoin add(String field) {
+ if(field == null) {
+ throw new RuntimeException("added value must not be null");
+ }
+ if(builder.length() > 0) {
+ builder.append(this.delimiter);
+ }
+ builder.append(field);
+ return this;
+ }
+
+ @Override
+ public String toString() {
+ return builder.toString();
+ }
+}