안녕하세요! 또 다른 저녁, 집으로 돌아가는 길에 나는 우편함을 확인하기로 결정했습니다. 내 이메일 받은 편지함이 아니라 우체부가 실제 편지를 넣는 구식 실제 상자를 의미합니다. 그리고 놀랍게도 거기에서 안에 뭔가가 들어 있는 봉투를 발견했습니다! 그것을 펼치면서 나는 그것이 호그와트에서 수십 년 지연된 편지이기를 바라며 잠시 시간을 보냈다. 그러나 나는 그것이 은행에서 보낸 지루한 "성인" 편지라는 것을 깨닫고 지구로 다시 내려와야 했습니다. 나는 텍스트를 훑어보고 멋진 아이들을 위한 나의 "디지털 전용" 은행이 현지 시장에서 가장 큰 회사에 인수되었다는 것을 깨달았습니다. 그리고 새로운 시작의 상징으로 봉투에 다음과 같은 내용을 추가했습니다.
사용 방법에 대한 지침과 함께
저와 같고 이러한 기술 혁신을 본 적이 없다면 편지에서 배운 내용을 공유하겠습니다. 새로운 소유자는 회사의 보안 정책을 시행하기로 결정했습니다. 이제부터 계정에는 MFA가 활성화됩니다(그 점에 대해서는 찬사를 보냅니다, btw). 그리고 위에서 볼 수 있는 장치는 은행 계좌에 로그인하는 동안 두 번째 요소로 사용되는 6자리 길이의 일회성 토큰을 생성합니다. 기본적으로 Authy, Google Authenticator 또는 2FAS와 같은 앱과 동일한 방식으로 작동하지만 물리적인 형태입니다.
그래서 시도해 보았는데 로그인 과정은 순조롭게 진행되었습니다. 기기에 6자리 코드가 표시되었고, 뱅킹 앱에 입력한 결과 로그인이 되었습니다. 만세! 그런데 뭔가 충격을 받았습니다. 이게 어떻게 작동하는 걸까요? 어떻게든 인터넷에 연결될 방법은 없지만 은행 서버에서 허용하는 올바른 코드를 생성합니다. 흠... 내부에 SIM 카드나 비슷한 것이 들어 있지 않을까요? 안돼요!
내 인생은 결코 예전과 같지 않을 것이라는 사실을 깨닫고 위에서 언급한 앱(오시와 친구들)에 대해 궁금해지기 시작했습니다. 내 내면의 연구자가 깨어났기 때문에 나는 전화기를 비행기 모드로 전환했고 놀랍게도 오프라인에서도 완벽하게 작동한다는 것을 깨달았습니다. 그들은 앱 서버에서 허용되는 코드를 계속 생성합니다. 흥미롭습니다!
당신은 어떤지 모르겠지만 저는 항상 일회성 토큰 흐름을 당연하게 여겼고 실제로 그것에 대해 제대로 생각해 본 적이 없습니다. (특히 요즘에는 휴대폰에 인터넷이 없는 경우가 드물기 때문입니다. 나는 야외 모험을 하고 있습니다.) 그것이 제가 놀랐던 근본 원인이었습니다. 그렇지 않으면 생성 프로세스가 순전히 로컬이므로 외부 행위자로부터 안전하므로 보안 관점에서 이러한 방식으로 작업하는 것이 완벽합니다. 그런데 어떻게 작동하나요?
Google이나 ChatGPT와 같은 최신 기술을 사용하면 쉽게 답을 찾을 수 있습니다. 하지만 이 기술적인 문제는 나에게 재미있어 보였기 때문에 먼저 직접 시도해 보고 해결하기로 결정했습니다.
현재 가지고 있는 것부터 시작해 보겠습니다.
서버 유효성 검사 부분에서는 서버가 오프라인 장치와 동일한 코드를 생성하여 비교할 수 있어야 함을 암시합니다. 흠..도움이 되겠네요.
새 "장난감"에 대한 추가 관찰을 통해 더 많은 발견을 할 수 있었습니다.
내가 생각해 낼 수 있는 유일한 논리적 설명은 이러한 코드에 특정 수명이 있다는 것입니다. "1-2-3-...-N" 방식으로 지속 시간을 계산하려고 시도한 이야기를 하고 싶지만 사실이 아닐 것입니다. 다음과 같은 앱에서 큰 힌트를 얻었습니다. Authy and Co에서는 30초 TTL을 보았습니다. 잘 찾았네요, 알려진 사실 목록에 추가해 보겠습니다.
지금까지의 요구 사항을 요약해 보겠습니다.
좋아요. 하지만 주요 질문은 여전히 답이 없습니다. 어떻게 오프라인 앱이 다른 앱의 가치와 일치하는 가치를 생성할 수 있을까요? 공통점은 무엇입니까?
반지의 제왕 세계관에 관심이 있으시다면 빌보가 골룸과 수수께끼 게임을 하고 다음 문제를 풀었던 방법을 기억하실 것입니다.
만물이 삼키는 것:
새, 짐승, 나무, 꽃;
쇠를 갉아먹고, 쇠를 물어뜯는다;
단단한 돌을 갈아서 식사를 합니다.
왕을 죽이고, 마을을 폐허로 만들고,
그리고 높은 산을 쳐서 무너뜨립니다.
스포일러 경고입니다. Baggins 씨는 운이 좋게도 우연히 정답인 "Time!"을 생각해 냈습니다. 믿거나 말거나지만 이것이 바로 우리 수수께끼에 대한 답이기도 합니다. 2개(또는 그 이상)의 앱은 시계가 내장되어 있는 한 동일한 시간에 액세스할 수 있습니다. 후자는 요즘에는 문제가 되지 않는데, 문제의 기기는 거기에 맞을 만큼 크다. 주위를 둘러보면 손시계, 휴대폰, TV, 오븐, 벽시계의 시간이 똑같을 확률이 높습니다. 뭔가 푹 빠져있는데, OTP(일회용 비밀번호) 컴퓨팅의 기반을 찾은 것 같아요!
시간에 의존하는 데에는 나름의 과제가 있습니다.
하나씩 살펴보겠습니다.
자, 이제 해결되었으므로 시간을 기반으로 알고리즘의 첫 번째 버전을 구현해 보겠습니다. 우리는 6자리 결과에 관심이 있으므로 사람이 읽을 수 있는 날짜보다는 타임스탬프에 의존하는 것이 현명한 선택처럼 들립니다. 거기서부터 시작해 보세요:
// gets current timestamp: current := time.Now().Unix() fmt.Println("Current timestamp: ", current)
Go 문서에 따르면 .Unix()는
1970년 1월 1일 UTC 이후 경과된 시간(초)
터미널에 인쇄된 내용은 다음과 같습니다.
Current timestamp: 1733691162
좋은 시작이지만 해당 코드를 다시 실행하면 타임스탬프 값이 변경되지만 30초 동안 안정적으로 유지하려고 합니다. 자, 케이크 조각을 30으로 나누고 그 값을 기본으로 사용하겠습니다.
// gets current timestamp current := time.Now().Unix() fmt.Println("Current timestamp: ", current) // gets a number that is stable within 30 seconds base := current / 30 fmt.Println("Base: ", base)
실행해 보겠습니다.
Current timestamp: 1733691545 Base: 57789718
그리고 다시:
Current timestamp: 1733691552 Base: 57789718
기본값은 동일하게 유지됩니다. 잠시 기다렸다가 다시 실행해 보겠습니다.
Current timestamp: 1733691571 Base: 57789719
30초가 지나서 기본 값이 변경되었습니다. 수고하셨어요!
'30으로 나누기' 논리가 이해되지 않으면 간단한 예를 들어 설명하겠습니다.
이제 좀 더 이해가 되기를 바랍니다.
그러나 아직 모든 요구 사항이 충족되지는 않습니다. 6자리 결과가 필요하고 현재 기준은 8자리이지만 미래에는 9자리에 도달할 수도 있습니다. . 음, 또 다른 간단한 수학 트릭을 사용해 보겠습니다. 밑수를 1,000,000으로 나누고 나머지를 구합니다. 나머지는 항상 정확히 6자리입니다. 알림은 0에서 999,999 사이의 숫자일 수 있지만 더 클 수는 없습니다.
// gets current timestamp: current := time.Now().Unix() fmt.Println("Current timestamp: ", current)
fmt.Sprintf(" d", code) 부분은 코드 값이 6자리 미만인 경우 앞에 0을 추가합니다. 예를 들어 1234는 001234로 변환됩니다.
이 게시물의 전체 코드는 여기에서 확인할 수 있습니다.
다음 코드를 실행해 보겠습니다.
Current timestamp: 1733691162
좋아요, 6자리 코드를 얻었습니다. 만세! 하지만 여기서는 뭔가 느낌이 좋지 않죠? 제가 이 코드를 제공하고 여러분도 저와 동시에 실행한다면 여러분도 저와 같은 코드를 얻게 될 것입니다. 그렇다고 해서 이것이 안전한 일회용 비밀번호가 되는 것은 아닙니다. 그렇죠? 새로운 요구사항이 생겼습니다:
물론 사용자가 100만 명을 넘으면 충돌이 불가피합니다. 이는 6자리당 가능한 최대 고유 값이기 때문입니다. 그러나 이는 현재와 같은 알고리즘 설계 결함이 아니라 드물고 기술적으로 피할 수 없는 충돌입니다.
어떤 영리한 수학적 트릭도 그 자체로는 우리에게 도움이 될 것이라고 생각하지 않습니다. 사용자별로 별도의 결과가 필요한 경우 이를 실현하려면 사용자별 상태가 필요합니다. 엔지니어이자 동시에 많은 서비스의 사용자로서 우리는 API에 대한 액세스 권한을 부여하기 위해 서비스가 사용자마다 고유한 개인 키에 의존한다는 것을 알고 있습니다. 사용자를 구별하기 위해 사용 사례에 대한 개인 키를 소개하겠습니다.
개인 키를 1 000 000에서 999 999 999 사이의 정수로 생성하는 간단한 논리:
// gets current timestamp current := time.Now().Unix() fmt.Println("Current timestamp: ", current) // gets a number that is stable within 30 seconds base := current / 30 fmt.Println("Base: ", base)
개인키 간 중복을 방지하기 위한 방법으로 pkDb 맵을 사용하고 있으며, 중복이 감지되면 고유한 결과를 얻을 때까지 생성 로직을 한 번 더 실행합니다.
개인 키 샘플을 얻기 위해 다음 코드를 실행해 보겠습니다.
Current timestamp: 1733691545 Base: 57789718
코드 생성 로직 내에서 이 개인 키를 사용하여 개인 키마다 다른 결과를 얻도록 합시다. 개인 키는 정수 유형이므로 우리가 할 수 있는 가장 간단한 일은 이를 기본 값에 추가하고 나머지 알고리즘을 그대로 유지하는 것입니다.
Current timestamp: 1733691552 Base: 57789718
다른 개인 키에 대해 다른 결과가 생성되는지 확인하세요.
Current timestamp: 1733691571 Base: 57789719
우리가 원하고 기대했던 결과가 나왔습니다:
// gets current timestamp: current := time.Now().Unix() fmt.Println("Current timestamp: ", current) // gets a number that is stable within 30 seconds: base := current / 30 fmt.Println("Base: ", base) // makes sure it has only 6 digits: code := base % 1_000_000 // adds leading zeros if necessary: formattedCode := fmt.Sprintf("%06d", code) fmt.Println("Code: ", formattedCode)
매력적으로 작동합니다! 이는 개인 키가 나와 같은 사용자에게 전송되기 전에 코드를 생성하는 장치에 삽입되어야 함을 의미합니다. 이는 은행에 전혀 문제가 되지 않습니다.
이제 끝났나요? 글쎄요, 우리가 사용한 인위적인 시나리오에 만족할 경우에만 그렇습니다. 계정이 있는 서비스/웹 사이트에 대해 MFA를 활성화한 적이 있다면 웹 리소스에서 선택한 두 번째 요소 앱(Authy, Google Authenticator, 2FAS 등)으로 QR 코드를 스캔하라는 메시지를 표시하는 것을 보았을 것입니다. )을 클릭하면 앱에 비밀 코드가 입력되고 그 순간부터 6자리 코드가 생성되기 시작합니다. 또는 코드를 수동으로 입력할 수도 있습니다.
업계에서 사용되는 실제 개인키의 형식을 엿볼 수 있다는 점을 언급하고자 합니다. 일반적으로 다음과 같은 16-32자 길이의 Base32 인코딩 문자열입니다.
// gets current timestamp: current := time.Now().Unix() fmt.Println("Current timestamp: ", current)
보시다시피 이는 우리가 사용한 정수 개인 키와 상당히 다르며 이 형식으로 전환하면 현재 알고리즘 구현이 작동하지 않습니다. 논리를 어떻게 조정할 수 있나요?
간단한 접근 방식부터 시작하겠습니다. 다음 줄 때문에 코드가 컴파일되지 않습니다.
Current timestamp: 1733691162
이제부터 pk는 문자열 유형이므로 그럼 정수로 변환해 볼까요? 훨씬 더 우아하고 효율적인 방법이 있지만 제가 생각해낸 가장 간단한 방법은 다음과 같습니다.
// gets current timestamp current := time.Now().Unix() fmt.Println("Current timestamp: ", current) // gets a number that is stable within 30 seconds base := current / 30 fmt.Println("Base: ", base)
이는 문자열 데이터 유형에 대한 Java hashCode() 구현에서 많은 영감을 얻었으며 이는 우리 시나리오에 충분합니다.
조정된 논리는 다음과 같습니다.
Current timestamp: 1733691545 Base: 57789718
다음은 터미널 출력입니다.
Current timestamp: 1733691552 Base: 57789718
좋네요. 6자리 코드가 있습니다. 수고하셨어요. 다음 시간 창에 도달하고 다시 실행하기를 기다립니다.
Current timestamp: 1733691571 Base: 57789719
// gets current timestamp: current := time.Now().Unix() fmt.Println("Current timestamp: ", current) // gets a number that is stable within 30 seconds: base := current / 30 fmt.Println("Base: ", base) // makes sure it has only 6 digits: code := base % 1_000_000 // adds leading zeros if necessary: formattedCode := fmt.Sprintf("%06d", code) fmt.Println("Code: ", formattedCode)
보시다시피 심각한 보안 결함입니다. 왜 이런 일이 발생합니까? 기본 계산 논리를 살펴보면 두 가지 요소에 의존한다는 것을 알 수 있습니다.
현재 / 30을 가치 변화를 더욱 눈에 띄게 만들 수 있는 것으로 변환해야 합니다.
분산 OTP 값
// gets current timestamp: current := time.Now().Unix() fmt.Println("Current timestamp: ", current)
이렇게 하면 30초마다 1씩 밑이 바뀌더라도 hash() 함수 로직 내에서 사용된 후에는 일련의 곱셈 수행으로 인해 diff의 가중치가 증가하게 됩니다.
업데이트된 코드 예시는 다음과 같습니다.
Current timestamp: 1733691162
실행해 보겠습니다.
// gets current timestamp current := time.Now().Unix() fmt.Println("Current timestamp: ", current) // gets a number that is stable within 30 seconds base := current / 30 fmt.Println("Base: ", base)
붐! 여기서 마이너스 값은 어떻게 얻었나요? 글쎄, int64 범위가 부족한 것 같아서 값을 마이너스로 제한하고 다시 시작했습니다. 제 Java 동료는 hashCode() 동작을 통해 이에 대해 잘 알고 있습니다. 수정은 간단합니다. 결과에서 절대값을 취하면 빼기 기호가 무시됩니다.
Current timestamp: 1733691545 Base: 57789718
수정된 전체 코드 샘플은 다음과 같습니다.
Current timestamp: 1733691552 Base: 57789718
실행해 보겠습니다.
Current timestamp: 1733691571 Base: 57789719
이제 OTP 값이 배포되는지 확인하기 위해 다시 실행해 보겠습니다.
// gets current timestamp: current := time.Now().Unix() fmt.Println("Current timestamp: ", current) // gets a number that is stable within 30 seconds: base := current / 30 fmt.Println("Base: ", base) // makes sure it has only 6 digits: code := base % 1_000_000 // adds leading zeros if necessary: formattedCode := fmt.Sprintf("%06d", code) fmt.Println("Code: ", formattedCode)
마침내 괜찮은 솔루션이군요!
사실 제가 수동 구현 과정을 중단한 순간은 재미도 있었고 새로운 것을 배웠기 때문입니다. 그러나 이는 최선의 솔루션도 아니고 내가 함께 사용할 솔루션도 아닙니다. 무엇보다도 여기에는 큰 결함이 있습니다. 보시다시피 우리 논리는 해싱 논리와 타임스탬프 값으로 인해 항상 큰 숫자를 처리합니다. 즉, 1이나 시작하는 결과를 생성할 가능성이 거의 없습니다. 더 많은 0: 예를 들어 012345 , 001234 등은 완전히 유효하지만. 이로 인해 가능한 값이 100,000개 부족합니다. 이는 알고리즘의 가능한 결과 수에서 10%입니다. 이렇게 하면 충돌 가능성이 더 높아집니다. 멋지지 않아요!
실제 애플리케이션에서 사용되는 구현에 대해 자세히 다루지는 않겠지만, 궁금한 분들을 위해 살펴볼 가치가 있는 두 가지 RFC를 공유하겠습니다.
위의 RFC를 기반으로 의도한 방식으로 작동하는 의사코드 구현은 다음과 같습니다.
Current timestamp: 1733692423 Base: 57789747 Code: 789747
보시다시피 우리는 이에 매우 근접했지만 원래 알고리즘은 더 고급 해싱(이 예에서는 HMAC-SHA1)을 사용하고 일부 비트 연산을 수행하여 출력을 정규화합니다.
하지만 오늘이 끝나기 전에 한 가지 더 다루고 싶은 사항이 있습니다. 바로 보안입니다. OTP 생성 로직을 직접 구현하는 것은 권장하지 않습니다. 우리를 위해 OTP를 생성하는 라이브러리가 많이 있기 때문입니다. 오류의 여지는 크며, 외부의 악의적인 행위자가 발견하여 악용할 취약점과도 거리가 가깝습니다.
세대 논리를 올바르게 이해하고 테스트로 다루더라도 잘못될 수 있는 다른 사항이 있습니다. 예를 들어, 6자리 코드를 무차별 공격하는 데 얼마나 걸릴 것이라고 생각하시나요? 실험해 보세요:
// gets current timestamp: current := time.Now().Unix() fmt.Println("Current timestamp: ", current)
다음 코드를 실행해 보겠습니다.
Current timestamp: 1733691162
한 번 더:
// gets current timestamp current := time.Now().Unix() fmt.Println("Current timestamp: ", current) // gets a number that is stable within 30 seconds base := current / 30 fmt.Println("Base: ", base)
보시다시피 간단한 무차별 for 루프를 통해 코드를 추측하는 데 약 70ms가 걸립니다. 이는 OTP 수명보다 400배 빠릅니다! OTP 메커니즘을 사용하는 앱/웹사이트의 서버는 예를 들어 3번의 시도 실패 후 5~10초 동안 새 코드를 허용하지 않음으로써 이를 방지해야 합니다. 이 방법으로 공격자는 30초 내에 18~9회만 시도할 수 있으며 이는 100만 개의 가능한 값 풀에 충분하지 않습니다.
그리고 간과하기 쉬운 또 다른 것들이 있습니다. 다시 한번 말씀드리지만, 처음부터 구축하지 말고 기존 솔루션을 활용하세요.
어쨌든 오늘 새로운 내용을 배웠기를 바랍니다. 이제부터 OTP 논리는 여러분에게 미스터리가 되지 않을 것입니다. 또한 인생의 어느 시점에서 재현 가능한 알고리즘을 사용하여 오프라인 장치에서 일부 값을 생성해야 하는 경우 어디서부터 시작해야 할지 알 수 있습니다.
이 게시물을 읽어주셔서 감사합니다. 즐거운 시간 보내세요! =)
추신 새 게시물을 게시하면 이메일을 받습니다. 여기에서 구독하세요
P.P.S. 다른 멋진아이들처럼 저도 요즘 블루스카이 계정을 만들었으니 피드를 더욱 재미있게 만들 수 있게 도와주세요 =)
위 내용은 OTP 이해하기: 오프라인 토큰 생성의 논리의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!