google authenticator 工作原理

Google authenticator

介绍

Google authenticator是一个基于TOTP原理实现的一个生成一次性密码的工具,用来做双因素登录,市面上已经有很多这些比较成熟的东西存在,像是一些经常用到的U盾,以及数字密码等

参考文档

https://tools.ietf.org/html/rfc6238
https://tools.ietf.org/html/rfc4226

实现源码 Google authenticator版本

https://github.com/google/google-authenticator-android

先来看看google-authenticator-android里面实现的一个版本,基于TOTP:Time-based One-time Password Algorithm(基于时间的一次性密码算法)

1.拿到HMACSHA1计算之后的签名

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);
  }

2.生成数字码


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;
  }

3.流程

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秒的延迟

生成code的流程以及原因

参照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,也就是长度为4byte[]
   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(基于计数器)


版权声明:本文为veZunShao原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
THE END
< <上一篇
下一篇>>