1. 概述
现在很多小区都有一些企业免费更换新的门禁、对讲(这个就是前一、两年搞的风风火火的智慧社区、社区O2O等),这些门禁、对讲有一个很重要的特色就是可以使用手机开门、对讲。但有个问题的是,网络不一定稳定可靠,很多实施方案使用的还是民用宽带,甚至还是一些小的宽带提供商,稳定性可想而知。
注:网络只针对手机在线开门而言,一些开门方式不依赖网络的
对于网络不稳定,我发现有个厂商是这样做的,当发现通过网络发送开门指令失败,给出一个密码,让用户输入,从而避免网络不良的情况,无法开门
实际上,有些厂商也把这个功能作为访客密码使用,处理方法会稍微有点差别
很惭愧的是,具体我没怎么去体验,所以我没办法推导具体需求,但我猜大概需要满足以下基础需求:
- 密码会自动失效,例如两分钟内,不然把这个密码分享出去给访客,那风险就大了
- 密码不需要预先同步到设备,如果预先同步一批密码到设备,那个先用,那个后用,是比较麻烦的事情,而且用户不一定输入了该密码
- 非网络验证,毕竟是网络通信失败的时候使用
实现
OTP算法
基于以上猜测,我个人认为通过OTP(One Time Password)
实现起来比较容易。OTP应用很广泛,一些网银的U-KEY令牌就是典型。当然也有很多软件可以支持,诸如:Google Authenticator、LastPass等。
它的基本原理是:令牌和服务器存储相同的秘钥,当要验证的时候,流程如下:
- 令牌端用当前时间或者递增计数器(或者组合一起),和秘钥一起进行HMAC加密
- 按照一定规则,把加密结果转换成6-8位数字输出
- 用户在网页上提交生成的数字
- 服务器使用用户对应的秘钥按照令牌端的加密流程输出6-8位数字
- 比较用户输入和服务端产生,匹配表示令牌端和服务器存储的用户秘钥是匹配的,验证通过
如果使用时间作为计算输入,就是TOTP(Time-Based One-Time Password;如果把递增计数器作为计算输入,那么就是HTOP(HMAC-based One-Time Password)
安全性浅析:
- HMAC算法本身的安全性,这里比较多的文章分析
- 秘钥安全性,这里涉及比较多方面,这里列举几个
- 存储安全性,服务器认为是安全的,毕竟被攻破了,也没多大意义;令牌设计只能写入秘钥(或者一次性写入),不能读取秘钥,并且采用一定的手段把存储器保护起来
- 秘钥长短,秘钥长度不应该小于Hash算法长度,对于HMAC-SHA1,最小长度是20字节(160bit)
- TOTP生命周期,RFC建议在30秒,实际也有不少产品是60秒
实现
算法通常不需要我们实现,如果需要Java版本,rfc4226的appendix-C就是一份实现了。如果需要C/C++版本,可以使用 google-authenticator;如果需要Python,可以使用:pyotp。下面是Java实现(关键的方法:generateOTP我增加中文注释):
package com.example;
/*
* OneTimePasswordAlgorithm.java
* OATH Initiative,
* HOTP one-time password algorithm
*
*/
/* Copyright (C) 2004, OATH. All rights reserved.
*
* License to copy and use this software is granted provided that it
* is identified as the "OATH HOTP Algorithm" in all material
* mentioning or referencing this software or this function.
*
* License is also granted to make and use derivative works provided
* that such works are identified as
* "derived from OATH HOTP algorithm"
* in all material mentioning or referencing the derived work.
*
* OATH (Open AuTHentication) and its members make no
* representations concerning either the merchantability of this
* software or the suitability of this software for any particular
* purpose.
*
* It is provided "as is" without express or implied warranty
* of any kind and OATH AND ITS MEMBERS EXPRESSaLY DISCLAIMS
* ANY WARRANTY OR LIABILITY OF ANY KIND relating to this software.
*
* These notices must be retained in any copies of any part of this
* documentation and/or software.
*/
import java.io.IOException;
import java.io.File;
import java.io.DataInputStream;
import java.io.FileInputStream ;
import java.lang.reflect.UndeclaredThrowableException;
import java.security.GeneralSecurityException;
import java.security.NoSuchAlgorithmException;
import java.security.InvalidKeyException;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
/**
* This class contains static methods that are used to calculate the
* One-Time Password (OTP) using
* JCE to provide the HMAC-SHA-1.
*
* @author Loren Hart
* @version 1.0
*/
public class OneTimePasswordAlgorithm {
private OneTimePasswordAlgorithm() {}
// These are used to calculate the check-sum digits.
// 0 1 2 3 4 5 6 7 8 9
private static final int[] doubleDigits =
{ 0, 2, 4, 6, 8, 1, 3, 5, 7, 9 };
/**
* Calculates the checksum using the credit card algorithm.
* This algorithm has the advantage that it detects any single
* mistyped digit and any single transposition of
* adjacent digits.
*
* @param num the number to calculate the checksum for
* @param digits number of significant places in the number
*
* @return the checksum of num
*/
public static int calcChecksum(long num, int digits) {
boolean doubleDigit = true;
int total = 0;
while (0 < digits--) {
int digit = (int) (num % 10);
num /= 10;
if (doubleDigit) {
digit = doubleDigits[digit];
}
total += digit;
doubleDigit = !doubleDigit;
}
int result = total % 10;
if (result > 0) {
result = 10 - result;
}
return result;
}
/**
* This method uses the JCE to provide the HMAC-SHA-1
* algorithm.
* HMAC computes a Hashed Message Authentication Code and
* in this case SHA1 is the hash algorithm used.
*
* @param keyBytes the bytes to use for the HMAC-SHA-1 key
* @param text the message or text to be authenticated.
*
* @throws NoSuchAlgorithmException if no provider makes
* either HmacSHA1 or HMAC-SHA-1
* digest algorithms available.
* @throws InvalidKeyException
* The secret provided was not a valid HMAC-SHA-1 key.
*
*/
public static byte[] hmac_sha1(byte[] keyBytes, byte[] text)
throws NoSuchAlgorithmException, InvalidKeyException
{
// try {
Mac hmacSha1;
try {
hmacSha1 = Mac.getInstance("HmacSHA1");
} catch (NoSuchAlgorithmException nsae) {
hmacSha1 = Mac.getInstance("HMAC-SHA-1");
}
SecretKeySpec macKey =
new SecretKeySpec(keyBytes, "RAW");
hmacSha1.init(macKey);
return hmacSha1.doFinal(text);
// } catch (GeneralSecurityException gse) {
// throw new UndeclaredThrowableException(gse);
// }
}
private static final int[] DIGITS_POWER
// 0 1 2 3 4 5 6 7 8
= {1,10,100,1000,10000,100000,1000000,10000000,100000000};
/**
* This method generates an OTP value for the given
* set of parameters.
*
* @param secret the shared secret
* 共享秘钥,设备端和服务器端必须一致,每个设备必须不相同
*
* @param movingFactor the counter, time, or other value that
* changes on a per use basis.
* 运动因子,可以是计数器,时间,或者其他每次变更的值
*
* @param codeDigits the number of digits in the OTP, not
* including the checksum, if any.
* 输出的OTP密码位数
*
* @param addChecksum a flag that indicates if a checksum digit
* should be appended to the OTP.
* 是否添加校验
*
* @param truncationOffset the offset into the MAC result to
* begin truncation. If this value is out of
* the range of 0 ... 15, then dynamic
* truncation will be used.
* Dynamic truncation is when the last 4
* bits of the last byte of the MAC are
* used to determine the start offset.
* 截取偏移,OTP密码是从HMAC输出中截取部分转化成10进制数字
* 该参数表示需要截取的偏移,取值范围:0 ~ 15,如果不再这个范围内,
* 那么使用动态范围
*
* @throws NoSuchAlgorithmException if no provider makes
* either HmacSHA1 or HMAC-SHA-1
* digest algorithms available.
* @throws InvalidKeyException
* The secret provided was not
* a valid HMAC-SHA-1 key.
*
* @return A numeric String in base 10 that includes
* {@link codeDigits} digits plus the optional checksum
* digit if requested.
*/
static public String generateOTP(byte[] secret,
long movingFactor,
int codeDigits,
boolean addChecksum,
int truncationOffset)
throws NoSuchAlgorithmException, InvalidKeyException
{
// put movingFactor value into text byte array
String result = null;
int digits = addChecksum ? (codeDigits + 1) : codeDigits;
byte[] text = new byte[8];
for (int i = text.length - 1; i >= 0; i--) {
text[i] = (byte) (movingFactor & 0xff);
movingFactor >>= 8;
}
// compute hmac hash
byte[] hash = hmac_sha1(secret, text);
// put selected bytes into result int
int offset = hash[hash.length - 1] & 0xf;
if ( (0<=truncationOffset) &&
(truncationOffset<(hash.length-4)) ) {
offset = truncationOffset;
}
int binary =
((hash[offset] & 0x7f) << 24)
| ((hash[offset + 1] & 0xff) << 16)
| ((hash[offset + 2] & 0xff) << 8)
| (hash[offset + 3] & 0xff);
int otp = binary % DIGITS_POWER[codeDigits];
if (addChecksum) {
otp = (otp * 10) + calcChecksum(otp, codeDigits);
}
result = Integer.toString(otp);
while (result.length() < digits) {
result = "0" + result;
}
return result;
}
}
使用
该代码没什么依赖,下面是使用的例子:
package com.example;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Calendar;
import java.util.Scanner;
public class Main {
/**
* OTP秘钥
*/
private static byte[] otpKey = new byte[80];
/**
* 步长
*/
private static long STEP = 30;
/**
* 初始化秘钥,实际产品当中,服务器端应该从数据库中加载,设备端从安全存储区中加载
*/
static {
for (int i=0; i<otpKey.length; i++) {
otpKey[i] = 0;
}
}
/**
* 根据指定的时间产生OTP
*
* @param calendar 指定的时间
* @param step 步长
* @return 返回6位数的OTP密码
* @throws InvalidKeyException 如果秘钥无效,抛出该异常
* @throws NoSuchAlgorithmException 如果系统不支持HMAC-SHA1,抛出该异常
*/
public static String makeCode(final Calendar calendar, long step) throws InvalidKeyException, NoSuchAlgorithmException {
long timeInSeconds = calendar.getTimeInMillis() / 1000L;
timeInSeconds = timeInSeconds / step;
return OneTimePasswordAlgorithm.generateOTP(otpKey, timeInSeconds, 6, false, -1);
}
/**
* 检查秘钥
*
* 在实际项目当中,服务器和设备总是存在时间误差的,保守起见
* 验证过去,当前以及未来30秒的密码
*
* @param inputCode 用户输入的秘钥
* @param calendar 时间,默认就是当前时间
* @param step 步长
* @return 成功返回true
* @throws InvalidKeyException 如果秘钥无效,抛出该异常
* @throws NoSuchAlgorithmException 如果系统不支持HMAC-SHA1,抛出该异常
*/
public static boolean checkCode(String inputCode, final Calendar calendar, long step) throws InvalidKeyException, NoSuchAlgorithmException {
long timeInSeconds = calendar.getTimeInMillis() / 1000L;
timeInSeconds = timeInSeconds / step;
for (int i=-1; i<2; i++) {
String code = OneTimePasswordAlgorithm.generateOTP(
otpKey,
timeInSeconds + i,
6,
false,
-1);
if (code.equals(inputCode)) {
return true;
}
}
return false;
}
public static void main(String[] args) throws Exception {
// 输出当前时间密码
Calendar calendar = Calendar.getInstance();
System.out.println(makeCode(calendar, STEP));
System.out.print("输入:");
Scanner scan = new Scanner(System.in);
String read = scan.nextLine();
System.out.println("输入:" + read + " 验证:" + checkCode(read, Calendar.getInstance(), STEP));
}
}
运行结果:
在实际项目中应该注意:
- 每个设备的秘钥必须不同,否则同一时间,全部输出的秘钥是一致的
- 秘钥长度必须大于20字节
- 秘钥的生成,必须使用安全随机数,例如:java.security.SecureRandom(注意CVE-2013-7372)、/dev/random(或者 /dev/urandom)、CryptGenRandom (Windows);然后通过安全通道,同步给设备,或者出厂的阶段,通过某种方式烧入。
- 在例子中,可以重复使用密码,要不可重复使用,需要自己实现,这个看产品需求了。建议可以重复使用,不然用户输入了,没来得及开门又闭锁了,就麻烦了