시작하며
드디어 I_CRYPTO 프로젝트의 마지막 OTP 프로그램이다. 사실 이 otp도 여러 글로 나눠서 하려다 중요한건 함수 몇개 밖에 안되기 때문에 하나의 글로 끝내려고 한다. OTP는 One Time Password의 약자로 otp기기를 본 사람은 알겠지만 기계를 누르면 6자리 숫자가 30초 정도 단위로 기계에 출력되며, 해당 번호를 은행 사이트(보통 2차 인증 느낌으로)에 입력하면 인증이 완료되는 식이다. 그 조그만 기기가 당연히 인터넷에 연결되어 있을리가 없는데 실시간으로 변하는 번호가 어떻게 은행에서 인증을 할 수 있는 것일까? 답은 간단하다. 은행과 otp 기기 안에는 사전에 공유한 키가 있기 때문이다. 사전에 공유되어 있어 같은 시간대에 시간값을 토대로 랜덤한 번호를 생성한다면, 같은 시간대에는 동일한 번호를 생성할 수 있는 것이다. 사실 otp를 프로그램으로 구현하는 것은 그렇게 어렵지 않아, 생성 함수와 검증 함수 두개면 끝나는 일인데 내가 제대로 해보겠단 욕심에 너무 크게 삽질을 해놨다... 우선 하나씩 살펴보겠다.
1. OTP Project 구조
위는 I_CRYPTO에서 OTPProject의 구조이다. 이게 사실 otp 원리만 알기위해 번호 생성과 검증만 하면 됐는데.... 여하튼 하나씩 설명하면 Bank 디렉토리는 검증을 하는 은행 사이트로, otpVerify.c가 otp를 검증하는 코드이다. 그런데 makefile은 알겠고 serverDB.txt는 뭐냐면, 서버는 각 otp마다 어떤 키를 공유했는지 시리얼 넘버로 구분하여 저장하고 있어야 검증을 할 수 있다. 그게 바로 ServerDB.txt에 저장되어있다.
아래 createOTPMachine 디렉토리는 make명령어를 수행하고 내가 otp기계를 만들 디렉토리 명을 입력하면 해당 디렉토리에 otp기계를 생성하는 개념이다. 즉 createOTPMachine.c 파일을 실행하면 대충 test라는 디렉토리를 입력하면 test 디렉토리에 otpChip실행파일(이게 실제 otp기기라고 가정), serialNumber.txt(고객이 가지고 있어야할 시리얼 넘버), otpHD.txt(otp기기 하드디스크라고 보면된다) 가 자동으로 생성된다. 왜 삽질 했다고 했는지 느낌이 오지 않는가. otp원리를 공부할 때 사실 별 필요없는 코드인데, 뭔가 크게 만들어보고 싶어 만들어봤다....
아래 Doxygen은 이 otp 프로젝트를 문서화하는 파일이 들어있고 OTPChip은 실제 otp기기라고 생각하면 된다. otp_library는 이 otp 프로젝트에서 사용하는 다양한 함수들을 라이브러리화 한것이다. OTPPackage는 이 otp 프로젝트는 make package 명령어를 치면 이 프로젝트 전체에서 필요한 파일들만 따로 모아 .tar 방식으로 패키징하는데 그때 사용되는 디렉토리이다. 마지막 Settings는 키길이, 검증 시간과 같은 것들을 설정하는 부분이다. 중요한건 라이브러리에 있는 otp.c, otp.h파일을 제외하면 나머지 모든 코드들은 샘플 코드라고 생각하면 된다. 이 라이브러리에 있는 함수들을 이런식으로 이용해 otp를 생성 및 검증할 수 있어 라고 하는 예시라 settings에 있는 값을 건드리면 아마 오류가 발생할 것이다.
우선 이렇게까지가 프로젝트에 대한 설명이고, 이제 OTP에 대한 구현을 할 때, 어떤 점들을 생각해야 하는지 알아보겠다.
2. 구현전 생각해야 할 문제
1. 먼저 검증 시간이다. 고객에게 otp 번호 유효시간이 30초라고 안내했다고 하자. 만약 고객이 12시 00분 29초에 기계를 켰다고 가정해보자. 단순하게 30초 단위로 검증을 한다고 하면 은행 입장에서 12시 00분 30초 땡 하는 순간 인증 번호가 바뀌게 될 것이다. 그렇다면, 고객 입장에서는 인증 유효 시간이 단 1초밖에 되지 않는다고 느껴질 것이다. 즉 고객이 언제 otp 기기를 키더라도 30초의 유효 시간을 보장해주어야 한다. 어떻게 해야할까? 현재, 그리고 앞, 뒤 30초동안의 otp 번호를 갖고 있으면 된다. 즉 위처럼 고객이 12시 00분 29초에 키고 번호를 누를때 12시 00분 33초 쯤으로 이미 검증 시간이 지나있다하더라도 현 시점으로 앞뒤 30초의 범위, 즉 과거 값 12시 00분 00초~12시 00분 29초, 현재 12시 00분 30초 ~ 12시 00분 59초, 미래 12시 01분 00초 ~ 12시 01분 29초 이렇게 과거 현재 미래 각 30초단위로 값들을 전부 갖고 있다면, 고객이 언제 otp기기를 키더라도 무조건 최소 30초의 시간을 보장해줄 수 있다.
2. 또 하나 중요한 건, 기기와 서버간의 시간차이다. 서버는 계속해서 시간을 정확하게 보정할 수 있지만, 기기는 네트워크에 연결이 되어있지 않고 계속 쓰면 쓸수록 조금씩 시간에 오차가 발생할 수 있다. 예를들어 otp기기의 시간이 계속 늦어져 실제 시간은 12시 00분 00초인데 기기 시간은 11시 59분 58초로 2초 차이가 나는 현상이 발생할 수 있다. 이러면 정확하게 30초를 보장하기 어렵게 된다. 즉 고객이 문제를 제기했을 때, 혹은 어느정도 쓰면 otp기기가 시간차가 얼만큼 발생하는지를 은행이 알고 있다면, 해당 기기로 인증을 하려할때 2초의 딜레이를 보정해주어야할 것이다. 이 프로젝트에선 offset이란 변수를 더하거나 뺌으로써 시간의 차이를 보정한다.
3. 또 유효시간을 은행자체에서 변경할 수도 있다. 처음엔 30초로 유효시간을 정했으나, 만약 고객들이 30초가 너무 짧다고 컴플레인이 많이 들어와 이 시간을 늘리고 싶을 수 있다. 그러나 otpr기기는 30초마다 번호를 생성하는데 고객에게 앞으로 60초동안 인증이 됩니다라고 말하려면 어떻게 해야할까. 새로 만드는 기기는 모두 60초마다 번호를 생성하게 만들면 되지만, 이미 발급받은 otp가 30초 단위였는데 은행에서 검증시간을 60초로 늘려준다고 하면, 답은 간단하다. 원래는 과거, 현재, 미래 각 30초 단위로 값을 저장해 최소 30초를 보장해줬는데, 뒤로 한번더, 앞으로 한번더 저장하면 된다. 즉 과거 60초, 과거 30초, 현재, 미래 30초, 미래 60초에 해당하는 총 5개의 otp값을 검증해주면 30초짜리로도 60초의 유효시간을 보장받을 수 있게 된다. 이 프로그램에서는 step이라는 변수로 step이 1이면 앞뒤 한번씩, step이 2이면 앞 뒤 두번씩 검증하도록 설계하였다.
4. 번호를 추출하는 방법또한 중요한데, 그냥 번호를 추출하는 줄 알았으나 이것도 표준이 있었다. 우선 값을 생성하는 것에는 키를 이용하는 HMAC을 사용한다. 키와 메시지를 조합해 해쉬값을 생성하는 HMAC 함수는 우선 사전에 둘만 공유한 비밀키를 이용하여 값을 생성한다는 점에서, 키를 공유한 사람들만이 생성할 수 있는 값이기 때문에 이를 이용하는거 같다. 그렇게 나온 32바이트 중, 특정 바이트들을 추출하여 최종 6자리를 만든다. 이 특정 바이트를 추출하는 방법에는 추출 인덱스가 고정인 정적 방식과 인덱스가 변하는 동적 방식이 있다. 이 프로젝트는 표준을 보고 두가지 방식 모두 지원한다.
이렇게 꽤 많은 요소들을 생각해야 제대로 OTP번호를 생성하고 검증할 수 있다. 이제 코드를 보자.
3. generateOTP() 함수
uint32_t generateOTP(time_t currentTime, char* key,
uint32_t keyLength, uint32_t otpDigit,
uint32_t lifeTime, uint32_t mode,
uint32_t EXTRACT_START_INDEX, uint32_t EXTRACT_SIZE){
int ret = 0;
//int mac_id = EDGE_HMAC_ID_SHA256;
AES_KEY hmacKey;
uint8_t output[1024] = {0x00, };
uint32_t outputlength = 0;
uint32_t timer = 0;
struct tm* t;
t = localtime(¤tTime);
currentTime += (t->tm_sec/lifeTime * lifeTime) - t->tm_sec;
if(AES_set_encrypt_key(key, 256, &(hmacKey)) < 0){
printf("i_enc_init() key 생성 오류\n");
return 0;
}
HMAC(EVP_sha256(), &hmacKey, keyLength, (uint8_t*)¤tTime, sizeof(currentTime), output, &outputlength);
// ret = edge_mac(mac_id, key, keyLength, (uint8_t*)¤tTime, sizeof(currentTime), output, &outputlength);
// if(ret != 0){
// printf("edge_mac %d\n", ret);
// return 0;
// }
uint32_t OTP = 0;
if(mode == 2)//Dynamic mode, output의 마지막 값에 하위 4비트를 시작 인덱스로 사용.
EXTRACT_START_INDEX = output[outputlength-1] & 0x0f;
for(int i=EXTRACT_START_INDEX;i<EXTRACT_START_INDEX+EXTRACT_SIZE;i++)
OTP += (output[i] << i*8); //little endian
OTP %= (uint32_t)pow(10, otpDigit);
return OTP;
}
OTP번호를 생성하는 generateOTP() 함수이다. 매개변수에서 otpDigit은 otp자릿수 즉 otpDIgit이 6이면 otp는 6자리가 생성된다.
lifeTime은 생명주기로 30초면 해당 번호는 30초 동안 보이고 30초가 지나면 다른 번호로 바뀐다. mode는 아까 번호를 추출하는 표준에서 static인지 dynamic인지 정하는 것이고, EXTRACT_START_INDEX는 추출 시작 인덱스로, static이면 기존에 정해둔 값이고, dynamic이면 해당 함수에서 인덱스를 정한다. EXTRACT_SIZE는 몇비트만큼을 추출할지 크기를 정하는 값이고, 이는 미리 정해져 있다. 즉 static과 dynamic의 차이는 추출을 어디서부터 시작할지를 정할 때, 기존에 정해둔 값으로 할지, 아니면 그때그때 달라질지에 대한 차이이다.
currentTime += (t->tm_sec/lifeTime * lifeTime) - t->tm_sec;
해당 코드는 현재 시간에서 30초단위로 번호를 생성하기 위한 식으로, 만약 12시 00분 03초가 현재 시간, lifteTime이 30초라고 가정하자. 그럼 t->tm_sec은 3이고 3/30 * 30은 0일 것이다. 그럼 0 - 3 이 되어서 최종 값이 -3,
currentTime += -3 이면 최종적으로 currentTime은 12시 00분 00초가 될것이다. 즉 이렇게 하면 12시 00분 00초 부터 12시 00분 29초까지 30초동안 같은 값을 발생시킬 수 있다.
아래에서 이 값과 key를 이용해 HMAC() 값을 생성하고, 모드에 따라 값을 추출하는데, dynamic방식은 표준에 따르면, hmac으로 나온 값의 마지막 4비트가 시작 인덱스 이다. 즉 마지막 4비트 이기때문에 최댓값은 15, 시작 인덱스는 0~15바이트까지로 hmac에 아웃풋에 따라 바뀌며, 그 인덱스를 시작으로 미리 정해둔 EXTRACT_SIZE만큼으로 번호로 추출하는데, 리틀엔디안 방식으로 추출하여 넣는다. 그 후, pow(10, otpDigit)의 나머지로 자릿수를 맞추고 최종적으로 otp번호를 생성한다.
4. verifyOTP() 함수
int verifyOTP(uint32_t input, uint32_t validTime,
char* key, uint32_t keyLength,
uint32_t otpDigit, uint32_t lifeTime,
uint32_t mode, uint32_t EXTRACT_START_INDEX,
uint32_t EXTRACT_SIZE, uint32_t offset){
if(validatorFlag == 0){
printf("verifyOTP() : 값 검증이 이루어지지 않았습니다.\n");
return -2;
}
if(validatorFlag == -1){
printf("verifyOTP() : otp option 값 중에 유효하지 않은 값이 있습니다.\n");
return -3;
}
int i = 0;
int step = 0;
uint32_t otp = 0;
time_t currentTime = 0;
time(¤tTime);
currentTime += offset;// offset값으로 otp와 서버 시간 맞춤
//ex validTime = 60, lifeTime = 30 -> step = 2, || validTime = 40, lifeTime = 30 -> step = 2. 즉 validTime이 lifeTime의 정 배수가 아니면 유효시간 보장을 위해 + 1
if(validTime % lifeTime == 0) step = validTime / lifeTime;
else step = validTime / lifeTime + 1;
//ex step이 2일 때, -2 -1 0 1 2 순으로 반복
i -= step;
while(i<step+1){
otp = generateOTP((currentTime + (i*(int)lifeTime)), key, keyLength, otpDigit, lifeTime, mode, EXTRACT_START_INDEX, EXTRACT_SIZE);
if(otp == 0) return -1;
if(input == otp)
return 0;
i++;
}
return -1;
}
다음은 otp번호를 검증하는 verifyOTP() 함수이다. 매개변수에서 validTime은 아까 설명한 유효시간이다. 이 시간이 원래는 30초여서 otp기기의 lifeTIme이 30초가 되도록 만들었는데 검증시간을 늘려 validTime이 60이 된다면, validTime / lifeTime이 2가 되고 이게 step이라는 지역변수에 대입된다. 즉 step이 2이면 기기의 lifeTimed에서 현재시간 - lifeTIme * 2, 현재시간 - lifTime, 현재시간, 현재시간 + lifeTime, 현재시간 + lifeTime * 2 이렇게 총 다섯개의 otpr값을 검증하게 된다. offset은 시간 보정 값으로, 만약 고객이 사용하는 otp기기가 2초정도 보정이 필요하다고 하면 이 값을 이용해 시간을 보정할 수 있다. while문을 보면 카운터 i를 step만큼 빼어 주석처럼 반복을 돌며 해당 범위마다 generateOTP를 이용해 번호를 생성하고 이 값이 input(고객이 입력한 otp 번호)와 같은 값이 나오는지 검사한다. 사전에 몇가지 요소만 생각하면 구현자체는 어렵지 않은 것을 볼 수 있다. 이제 실제 실행해보자.
5. 실행
먼저 OTP를 생성할 디렉토리는 Test라는 이름으로 만들었다. Test 디렉토리를 만들고 OTPChip에서 make명령어를 수행해 otpChip 실행파일을 생성한다. 그 후 createOTPMachine에서 make, make test를 수행하고, Test 디렉토리를 입력하면 Test디렉토리에 자동으로 otpChip 실행파일과 otpHD.txt, serialNumCard.txt가 생성되는 것을 볼 수 있다.
Test 디렉토리에 자동으로 파일들이 생성된 모습이다. serial번호와 otpHD.txt 에는 무슨 정보가 있는지 확인해보자.
해당 기기의 시리얼 번호는 947601이고, 기기의 디스크에는 16바이트(키길이) abcd...(키) 자릿수 6, lifeTime 30, 추출 모드 1(스태틱, 2면 다이나믹), 추출 시작 인덱스 0, 추출크기 3이 적혀있다. 이 정보는 서버에 시리얼넘버와 함꼐 그대로 저장된다. 우선 otp기기를 생성한 Test디렉토리에서 ./otpChip으로 otp 번호를 생성해보자.
이렇게 밑에 보면 6자리 번호 : 시간 초 이렇게 출력되는데 이때 시간은 lifeTime만큼 흐르면 새로운 번호로 출력된다. 즉 0부터 29초까지는 같은 번호가 나오고 30초가 흐른순간 다시 0초부터 새로운 번호가 나온다. 이제 검증하는 쪽에 시리얼 번호를 입력하고 otp번호를 입력해 검증이 되는지 확인해보자.
Bank 디렉토리에 ServerDB.txt를 읽은 것이다. 전체 유효시간이 30초로 설정되어있는 것을 볼 수 있고, 밑에 방금 반든 otp기기의 시리얼 번호와 해당 기기의 정보들이 자동으로 저장된 것을 볼 수 있다. createOTPMachine을 실행하면, 서버에도 자동으로 정보를 쓴다는 말이다. Bank디렉토리에서 otpVerify.c를 입력하면, 처음 시리얼 번호를 입력받고, 이 txt파일에 해당 시리얼 번호와 매핑되는 정보가 있다면 이를 토대로 otp번호를 검증한다. 다음 화면을 보자.
왼쪽이 검증, 오른쪽이 otp기기로, 왼쪽에 시리얼번호와 otp값을 입력하자 인증에 성공하는 모습을 볼 수 있다. 이렇게 otp 프로그램을 구현했다고 보면 될 것 같다. 이 외에 이 otp 프로젝트에서는 위에서 말한대로, make package를 입력하면 자동으로 고객에게 전달할 파일들만 따로 .tar압축하여 패키징 하는 것 포함 이것저것 많이 해놨는데 자세한건 깃허브에서 readme를 읽어보면 될 것 같다.
마치며
이렇게 생각보다 오래걸렸는데 실습 때 만들었던 I_CRYPTO 프로젝트에 대한 모든 글이 끝났다. 너무 기분 좋고 다음 글부터는 아마 C++ 마저 쓰면서 해킹과 관련된 것들, 그리고 Django를 활용해 학교에서 사용할 워게임 사이트를 만든 것을 포스팅 할 것 같다. 지금까지 이 프로젝트를 쓰면서 느꼈던 것들은 다음 글에서 쓰면서 진짜 마무리를 해보겠다.
'C > Crypto' 카테고리의 다른 글
Crypto Project : Epilogue (1) | 2023.05.07 |
---|---|
Crypto Project : 2-3. TLS방식을 모방한 소켓프로그램 (0) | 2023.05.05 |
Crypto Project : 2-2. 소켓에서 select() 함수를 이용한 recv_all() 함수 (0) | 2023.04.26 |
Crypto Project : 2-1. 멀티쓰레드 기반 1대n 소켓 프로그램 (1) | 2023.04.25 |
Crypto Project : 1-6. crypto library makefile과 테스트 (0) | 2023.04.23 |