【歪门邪道】一种基于时间的OTP验证方法
本来是想着就最近的事情写点什么，但是总是没有合适的时间。从暑假开始一直到现在都在忙着给团委写代码，早上10点起床晚上11点上床，其间除了吃饭就是写代码，11点到凌晨2点看舍长的杀手2直播录像，看困了睡觉。最近发生的事情可能会在几个星期后再写吧，尽管那时侯可能好多人已经忘记了。总之书归正传，这次写的一个方法是应用在我手头的项目上的，我感觉我应该不是原创，毕竟有很多类似的方法，大体思路相同的那种。0x01 思路这个方法的创立是受到Yubico OTP的启发。不为众所周知：我最近公费买了个Yubikey 5 NFC，发现还挺有意思的，它的使用方法，例如FIDO、OTP、静态密码和OpenPGP之类的部分在网上都已经有很详尽的说明了，如果只是记述我重复人家的操作大概会很没意思，所以关于这个的文章我就没写。但是简单地说，Yubico OTP是Yubico自己家的动态验证码。这里可以找到官方的解释。简而言之就是Yubikey设备内会持有一个AES密钥和一个计数器，而Yubico官方的验证服务器也同样持有AES密钥和一个计数器。每次Yubikey产生的OTP字符串都是经过AES加密的，其中含有计数器信息。如果一个字符串能够被正确解密且计数器次数是大于服务器上储存的次数的话，就认为验证通过，否则就是重放攻击。但其实这样的机制也不是安全，例如试先储存好生成的字符串，在你本人利用新的字符串验证身份之前（此时服务器上的计数还未更新），我（假设是攻击者）就已经利用这些预先准备的字符串向服务器提交，同样会验证通过，而你之后也不会发现（假设验证后第三方不会记录登陆日志等信息）。但着手于手头项目的需要：一个动态变化的URL能够指向同一个实体，并且要求这个URL是时间新鲜1的。因此我想到了使用类似Yubico OTP的方法来生成这个URL。0x02 细节首先URL中可变的部分称为token，一个合法的token是44个字符长。其中前12个字符标识一个具体唯一的实体，称为uid；后32个字符是仅针对该实体和当前时间有效的，称为payload。uid可以自己定，只要保证一个uid对应唯一一个可辨识的实体即可，其长度也可以随意，我选择12个字符长度。而payload则是利用实体持有的AES密钥和生成的初始化向量进行加密，加密内容是当前的时间戳。其中初始化向量一共16字节，前8个字节是使用URL安全的Base64编码过的AES密钥的前八个字符，后8个字节是当前时间戳（long型）对应的字节。这个初始化向量可以凭自己喜好定义，但是如果要求对时间敏感，则最好不要把初始化向量设定为固定的。当服务器验证时首先分离uid和payload，通过uid查询到实体对应的AES密钥，使用其和相同算法生成的初始化向量解密，如果能够正确解密得到时间戳，则认为验证通过。这里为了容纳服务器和客户端之间的时间差，服务器应当验证当前时间戳、上一个时间戳和下一个时间戳下不同的可能性，当其中任一情况下通过验证，则可以认为token有效。0x03 编程实现（Java）工具类package info.skyblond.velvet.scarlatina; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.crypto.*; import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.Arrays; import java.util.Base64; import java.util.Objects; /** * Handle things related with tokens */ public class Token { private static final Logger logger = LoggerFactory.getLogger(Token.class); private SecretKeySpec keySpec = null; /** * @param base64UrlSafeKey The base64(URL safe) encoded key which will be used in following operations. */ public Token(String base64UrlSafeKey){ Objects.requireNonNull(base64UrlSafeKey); keySpec = new SecretKeySpec(decodeBase64(base64UrlSafeKey), "AES"); } /** * @return A random based64(URL safe) encoded AES 256bits key, return null when failed. */ public static String generateKey(){ try { SecureRandom random = SecureRandom.getInstanceStrong(); KeyGenerator keyGen = KeyGenerator.getInstance("AES"); keyGen.init(256, random); SecretKey key = keyGen.generateKey(); return encodeBase64(key.getEncoded()); }catch (NoSuchAlgorithmException e){ logger.error(e.toString()); return null; } } /** * @return A random based64(URL safe) encoded 12 characters long Uid */ public static String generateUid(){ try { SecureRandom random = SecureRandom.getInstanceStrong(); byte[] uid = new byte[9]; random.nextBytes(uid); return encodeBase64(uid); }catch (NoSuchAlgorithmException e){ logger.error(e.toString()); return null; } } /** * @return Generate a based64(URL safe) encoded, teacher specific, time sensitive token with current timestamp */ public String generateToken(){ return generateToken(System.currentTimeMillis() / 1000); } /** * @return Generate a based64(URL safe) encoded, teacher specific, time sensitive token with a given timestamp */ private String generateToken(long epoch){ if(keySpec == null) return null; long epochInMinute = cutEpochToMinute(epoch); byte[] input = longToBytes(epochInMinute); byte[] iv = generateIv(bytesToLong(Arrays.copyOfRange(keySpec.getEncoded(), 0, 8)) , epochInMinute); GCMParameterSpec spec = new GCMParameterSpec(128, iv); try { Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.ENCRYPT_MODE, keySpec, spec); byte[] cipherText = cipher.doFinal(input); return encodeBase64(cipherText); } catch (Exception e) { logger.error(e.toString()); return null; } } /** * @param base64UrlSafeToken The based64(URL safe) encoded token * @return true if token is validate with current time */ public boolean validateToken(String base64UrlSafeToken){ return validateToken(System.currentTimeMillis() / 1000, decodeBase64(base64UrlSafeToken)); } /** * @param epoch A given timestamp in second * @param cipherText Token in raw byte[] status * @return true if token is validate with given time. Allow ±1 minutes error.(current minute, last and next minute, 3 minutes in total) */ private boolean validateToken(long epoch, byte[] cipherText){ long baseEpoch = cutEpochToMinute(epoch); //validate right now, this only be correct(>0) if can decrypt if(parseToken(baseEpoch, cipherText) > 0){ return true; } //validate one minutes ago if(parseToken(baseEpoch - 60, cipherText) > 0){ return true; } //validate one minutes later return parseToken(baseEpoch + 60, cipherText) > 0; } /** * @param epoch A given timestamp in second * @param cipherText Token in raw status * @return the timestamp when token is created if correct, -1 when incorrect */ private long parseToken(long epoch, byte[] cipherText){ if(keySpec == null) return -1; byte[] iv = generateIv(bytesToLong(Arrays.copyOfRange(keySpec.getEncoded(), 0, 8)) , epoch); GCMParameterSpec spec = new GCMParameterSpec(128, iv); try { Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.DECRYPT_MODE, keySpec, spec); byte[] plainText = cipher.doFinal(cipherText); return bytesToLong(plainText); }catch (Exception e) { return -1; } } /** * @param para1 first parameter * @param para2 second parameter * @return a 16 byte long Iv generate using 2 given parameters */ private static byte[] generateIv(long para1, long para2){ byte[] result = new byte[16]; int i = 15; //0-7, 8-15 for(; i > 8; i--){ result[i] = (byte)(para2 & 0xFF); para2 >>= 8;//shift a byte } for(; i > 0; i--){ result[i] = (byte)(para1 & 0xFF); para1 >>= 8;//shift a byte } return result; } /** * @param epoch A given timestamp in second * @return A timestamp cut down to floor in minute. */ public static long cutEpochToMinute(long epoch){ return epoch - (epoch % 60);//remove second } /** * @param x a long value * @return corresponding bytes */ private static byte[] longToBytes(long x) { ByteBuffer buffer = ByteBuffer.allocate(Long.BYTES); buffer.putLong(x); return buffer.array(); } /** * @param bytes 8 bytes * @return corresponding long value */ private static long bytesToLong(byte[] bytes) { ByteBuffer buffer = ByteBuffer.allocate(Long.BYTES); buffer.put(bytes); buffer.flip();//need flip return buffer.getLong(); } /** * @param source bytes * @return A string encoded with url safe base64 */ private static String encodeBase64(byte[] source){ return new String(Base64.getUrlEncoder().encode(source), StandardCharsets.UTF_8); } /** * @param target A string encoded with url safe base64 * @return decoded bytes */ private static byte[] decodeBase64(String target){ return Base64.getUrlDecoder().decode(target.getBytes(StandardCharsets.UTF_8)); } }其中各方法功能如下： /** * 构造函数，需要使用指定的AES密钥进行初始化，密钥不可为null或"" */ public Token(String base64UrlSafeKey); /** * 随机生成一个URL安全的Base64编码的AES密钥（256位） */ public static String generateKey(); /** * 随机生成一个12字符长度的uid。基于Base64编码规则：源字节数 * 8 / 6 = 编码后字符数，不能被6整除时依据余数补充1到2个等号 */ public static String generateUid(); /** * 基于当前时间戳生成一个Token，本质上是调用下面的函数 */ public String generateToken(); /** * 对给定时间戳生成一个Token */ private String generateToken(long epoch); /** * 验证Token，将当前时间戳和解码后的Token传递给下面的函数 */ public boolean validateToken(String base64UrlSafeToken); /** * 验证Token，对当前分钟的时间戳、前一分钟的时间戳和后一分钟的时间戳调用下面的parseToken函数，若parseToken函数能够返回正确的的时间戳则认为验证通过。 */ private boolean validateToken(long epoch, byte[] cipherText); /** * 针对给定的时间戳和密文尝试解密密文，成功则返回解密后的时间戳 */ private long parseToken(long epoch, byte[] cipherText); /** * 利用两个long型变量生成初始化向量，每个long型占8字节 */ private static byte[] generateIv(long para1, long para2); /** * 将时间戳向下取整到分钟 */ public static long cutEpochToMinute(long epoch); /** * 将long型转换为字节数组 */ private static byte[] longToBytes(long x); /** * 将长度为8的字节数组转换为long型 */ private static long bytesToLong(byte[] bytes); /** * 返回经过URL安全的Base64编码的字节数组 */ private static String encodeBase64(byte[] source); /** * 解码URL安全的Base64字符串，返回字节数字 */ private static byte[] decodeBase64(String target);其中不依赖实体密钥的方法全都作为静态方法实现。-End-（7月的文章终于水出来了2333） 时间新鲜是指该URL对时间敏感，只能在一定时间内有效，过时则无效。顺便一提这个词是我瞎编的。 ↩