Google authenticator是一个基于TOTP原理实现的一个生成一次性密码的工具,用来做双因素登录,市面上已经有很多这些比较成熟的东西存在,像是一些经常用到的U盾,以及数字密码等
https://tools.ietf.org/html/rfc6238
https://tools.ietf.org/html/rfc4226
https://github.com/google/google-authenticator-android
先来看看google-authenticator-android里面实现的一个版本,基于TOTP:Time-based One-time Password Algorithm(基于时间的一次性密码算法)
static Signer getSigningOracle(String secret) {
try {
//拿到secret base32之后的byte数组
byte[] keyBytes = decodeKey(secret);
//使用hmacsha1计算字符串的签名
final Mac mac = Mac.getInstance("HMACSHA1");
mac.init(new SecretKeySpec(keyBytes, ""));
// Create a signer object out of the standard Java MAC implementation.
return new Signer() {
@Override
public byte[] sign(byte[] data) {
return mac.doFinal(data);
}
};
} catch (DecodingException error) {
....
}
return null;
}
private static byte[] decodeKey(String secret) throws DecodingException {
return Base32String.decode(secret);
}
public String generateResponseCode(long state)
throws GeneralSecurityException {
byte[] value = ByteBuffer.allocate(8).putLong(state).array();
return generateResponseCode(value);
}
public String generateResponseCode(byte[] challenge)
throws GeneralSecurityException {
//通过HMACSHA1拿到签名后的byte数组
byte[] hash = signer.sign(challenge);
// Dynamically truncate the hash
// OffsetBits are the low order bits of the last byte of the hash
//动态拿到offset 0xF 二进制 1111
//offset是去掉高位后的数据 保留后四位
int offset = hash[hash.length - 1] & 0xF;
// Grab a positive integer value starting at the given offset.
//把hash转换成int
// 0x7FFFFFFF 1111111111111111111111111111111
//offset 就是截取过后的hash拿到的Int value
int truncatedHash = hashToInt(hash, offset) & 0x7FFFFFFF;
//然后根据code length拿到具体需要多少位
int pinValue = truncatedHash % DIGITS_POWER[codeLength];
return padOutput(pinValue);
}
private int hashToInt(byte[] bytes, int start) {
DataInput input = new DataInputStream(
new ByteArrayInputStream(bytes, start, bytes.length - start));
int val;
try {
val = input.readInt();
} catch (IOException e) {
throw new IllegalStateException(e);
}
return val;
}
1.客户端和服务端使用HMACSHA1 这个加密算法进行加密,首先客户端和服务端会商量一个key,然后把这个key Base32之后当作算法的加密key
2.然后客户端和服务端需要维持一个long state
一个值,如果服务端和客户端不能够通信,那么其实用时间当作这个state即可,当然这个得保证估计几十秒的容错性,一旦时间误差比较大就会验证不通过,这个就是TOTP的缺点。
3.双方通过加密这个long state
得到统一加密后的数据byte[] hash
,首先取byte[] hash
的最后一个byte& 0xF,那么就是去掉高位,留下一个小于15的数字offset
,然后通过截取hash[offset:length]
,这部分byte[]数组转换成数字truncatedHash
,最后根据设置的返回码的位数,来决定取truncatedHash
中的多少位。
整个算法流程就是上面这三步,其实没有什么高大上的东西,这里面有一个难点,就是保证时间的容错性,
mTotpCounter.getValueAtTime(Utilities.millisToSeconds(mTotpClock.currentTimeMillis()));
首先是mTotpClock.currentTimeMillis()
拿到当前毫秒的时间戳,然后是 Utilities.millisToSeconds
把毫秒转换成秒,最后是mTotpCounter.getValueAtTime
,这里做了时间的容错
public long getValueAtTime(long time) {
assertValidTime(time);
// According to the RFC:
// T = (Current Unix time - T0) / X, where the default floor function is used.
// T - counter value,
// T0 - start time.
// X - time step.
// It's important to use a floor function instead of simple integer division. For example,
// assuming a time step of 3:
// Time since start time: -6 -5 -4 -3 -2 -1 0 1 2 3 4 5 6
// Correct value: -2 -2 -2 -1 -1 -1 0 0 0 1 1 1 2
// Simple division / 3: -2 -1 -1 -1 0 0 0 0 0 1 1 1 2
//
// To avoid using Math.floor which requires imprecise floating-point arithmetic, we
// we compute the value using integer division, but using a different equation for
// negative and non-negative time since start time.
long timeSinceStartTime = time - mStartTime;
if (timeSinceStartTime >= 0) {
return timeSinceStartTime / mTimeStep;//mTimeStep 这里默认是30
} else {
return (timeSinceStartTime - (mTimeStep - 1)) / mTimeStep;
}
}
返回的是timeSinceStartTime / mTimeStep;
mTimeStep
这里默认是30,也就是说可以忽略30秒的延迟
参照https://tools.ietf.org/html/rfc4226
第一步是通过HMAC-SHA-1生成一个长度为20byte数组
Step 1: Generate an HMAC-SHA-1 value Let HS = HMAC-SHA-1(K,C) // HS
is a 20-byte string
第二步是通过动态截取之后返回的一个31位的string,也就是长度为4的byte[]
Step 2: Generate a 4-byte string (Dynamic Truncation)
Let Sbits = DT(HS) // DT, defined below,
// returns a 31-bit string
第三步byte[]转换成整数
Step 3: Compute an HOTP value
Let Snum = StToNum(Sbits) // Convert S to a number in
0...2^{31}-1
//最后根据自己需要code的位数,截取整数的一部分
Return D = Snum mod 10^Digit // D is a number in the range
0...10^{Digit}-1
核心在第二步的DT函数
DT(String) // String = String[0]...String[19]
Let OffsetBits be the low-order 4 bits of String[19]
Offset = StToNum(OffsetBits) // 0 <= OffSet <= 15
Let P = String[OffSet]...String[OffSet+3]
Return the Last 31 bits of P
先来看看OffsetBits是如何计算出来的
int offset = hash[hash.length - 1] & 0xF;
其实就是取byte数组的最后一个byte也就是byte[19]然后跟0xF做&运算,运算之后得到的一定是一个0<=offset<=15
的数,而为什么要这个区间的一个数呢?首选是一个int可以用4个byte来存储,其次总的byte的长度是20,最大15+4刚好取到byte[19]没有超过。
最后是拿到一个只有31位的整数,来看看代码是如何计算的
int binary = ((hash[offset] & 0x7f) << 24) | ((hash[offset + 1] & 0xff) << 16)
| ((hash[offset + 2] & 0xff) << 8) | (hash[offset + 3] & 0xff);
其他都很正常取了四个字节,转换成整数,然是注意看最高位(hash[offset] & 0x7f) << 24)
这里从0xff
变成了0x7f
也就是127(1111111),二进制少了1位,也就是把高位干掉了,32位变成了31位,为什么要使用31位呢?rfc文档解释如下:
The reason for masking the most significant bit of P is to avoid
confusion about signed vs. unsigned modulo computations. Different
processors perform these operations differently, and masking out the
signed bit removes all ambiguity.
用中文来解释大概意思就是关于符号与无符号模运算的混淆。不同
处理器以不同的方式执行这些操作,并屏蔽掉
符号位消除所有歧义。大概就是这个意思。
原理想通的算法还有一个:
HOTP(基于计数器)