TOTP란 Time을 base로하여 생성하는 One-Time-Password를 의미한다. 시간을 공유키로 생성하기 때문에 정해진 유효시간을 지나면 매회 새롭게 키가 생성되며 일회성의 성질을 갖게된다. 서버와 클라이언트는 공유 비밀키와 시간정보를 사용하여 이러한 동작이 가능하게 된다.
RFC6238 문서를 보면 TOTP는 HMAC, HOTP 알고리즘을 사용한다고 명시한다. 그러므로 우선 해당 개념들을 이해하는게 먼저다.
HMAC(Key, message) = SHA1(Key XOR Opad + SHA1(Key XOR Ipad + message))
HOTP 알고리즘은 해쉬 값을 변환하여 정수로 이루어진 값을 반환합니다. 정수로 만들어지는 이유는 사람이 입력하기 편하기 때문입니다.
HOTP(Key, message) = Truncate(HMAC(Key, message))
해당 글로만 보면 정확히 이해가 가지 않을 수 있다. 코드를 한번 살펴보면 이해가 쉽다.
여기서 key는 클라이언트와 공유할 공유키이다. time은 현재 시간이고, returnDigits는 몇자리수로 OTP 값을 얻을지 결정하는 값이다. crypto는 TOPT 생성에 사용할 암호화 알고리즘명이다. 위에서 언급했던듯이 SHA1, SHA256, SHA512등을 사용할 수 있다.
while (time.length() < 16) {
time = "0" + time;
}
hexString으로 전달받은 시간을 헥스자리수로 맞춰준다.
// get hex in a byte[]
byte[] msg = hexStrToBytes(time);
byte[] k = hexStrToBytes(key);
byte[] hash = hmac_sha(crypto, k, msg);
time과 공유키를 byte로 변환하고 지정한 crypto 방식으로 암호환다.
int offset = hash[hash.length - 1] & 0xf;
마지막 자리에서 offset을 구한다.
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];
result = Integer.toString(otp);
while (result.length() < codeDigits) {
result = "0" + result;
}
return result;
해당 오프셋부터 4byte값을 가져와 첫자리에서 부호 비트를 제거(0x7f)하고 unsigned hex 연산(0~255)을 해준다. 그러면 우리는 decimal 값 즉 int binary 획득이 가능하다. microsoft authenticator나 google authenticator를 보면 6자리이다. 6자릿수 OTP 값을 얻고 싶다면 returnDigits에 6을 넘기면 된다. 그러면 우리는 결국 최종적으로 returnDigits에 넘긴 숫자 만큼의 암호화 값(OTP)을 얻을 수 있게된다.
아래는 전체 코드이다.
public static String generateTOTP(String key,
String time,
String returnDigits,
String crypto) {
int codeDigits = Integer.decode(returnDigits).intValue();
String result = null;
// using counter
while (time.length() < 16) {
time = "0" + time;
}
// get hex in a byte[]
byte[] msg = hexStrToBytes(time);
byte[] k = hexStrToBytes(key);
byte[] hash = hmac_sha(crypto, k, msg);
// put selected bytes into result int
int offset = hash[hash.length - 1] & 0xf;
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];
result = Integer.toString(otp);
while (result.length() < codeDigits) {
result = "0" + result;
}
return result;
}
실제 테스트를 돌려보자면
@ExtendWith(MockitoExtension.class)
class TOTPTest {
@Test
@DisplayName("OTP TEST")
void signUp() {
// Seed for HMAC-SHA1 - 20 bytes
String seed = "3132333435363738393031323334353637383930";
// Seed for HMAC-SHA256 - 32 bytes
String seed32 = "3132333435363738393031323334353637383930" +
"313233343536373839303132";
// Seed for HMAC-SHA512 - 64 bytes
String seed64 = "3132333435363738393031323334353637383930" +
"3132333435363738393031323334353637383930" +
"3132333435363738393031323334353637383930" +
"31323334";
long T0 = 0;
long X = 30;
String steps = "0";
SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
df.setTimeZone(TimeZone.getTimeZone("Asia/Seoul"));
try {
long testTime = Instant.now().getEpochSecond();
long T = (testTime - T0) / X;
steps = Long.toHexString(T).toUpperCase();
while (steps.length() < 16) {
steps = "0" + steps;
}
String fmtTime = String.format("%1$-11s", testTime);
String utcTime = df.format(new Date(testTime * 1000));
System.out.print("| " + fmtTime + " | " + utcTime +
" | " + steps + " |");
System.out.println(generateTOTP(seed, steps, "6",
"HmacSHA1") + "| SHA1 |");
System.out.print("| " + fmtTime + " | " + utcTime +
" | " + steps + " |");
System.out.println(generateTOTP(seed32, steps, "6",
"HmacSHA256") + "| SHA256 |");
System.out.print("| " + fmtTime + " | " + utcTime +
" | " + steps + " |");
System.out.println(generateTOTP(seed64, steps, "6",
"HmacSHA512") + "| SHA512 |");
System.out.println(
"+---------------+-----------------------+" +
"------------------+--------+--------+");
} catch (final Exception e) {
System.out.println("e = " + e);
}
}
}
| 1702267674 | 2023-12-11 13:07:54 | 000000000361D16F |828476| SHA1 |
| 1702267674 | 2023-12-11 13:07:54 | 000000000361D16F |467927| SHA256 |
| 1702267674 | 2023-12-11 13:07:54 | 000000000361D16F |146706| SHA512 |
+---------------+-----------------------+------------------+--------+--------+
다음과 같은 결과를 얻을 수 있다.
멋있네요!