본문 바로가기

디미페이

Local Token 0.4 Release Note

로컬 생성 결제 토큰이 처음이라면, 이전 글을 먼저 읽어주세요!

 

최근 앱과 백엔드에서 로컬 생성 결제 토큰 구현을 시작했는데, 구현 중에서 치명적인 논리적 오류 찾아 사양 일부를 수정해야 했어요.

하지만 이번 업데이트는 단순히 이 오류만 고치는 것이 아니라, 암호화 알고리즘 교체, TLV 포맷 도입, 키 유도 함수 등 보안적으로도 큰 변경이 이루어졌어요.

TL;DR

  • 사양 논리 오류 수정
  • HKDF로 암호 키 유도
  • 오프라인 무차별 대입 공격(Offline brute-force attack) 대응
  • AES-GCM 사용 중지 및 XChaCha20-Poly1305 도입
  • 16 바이트를 줄여 QR 버전 5 사용 가능 (이전: 버전 6)
  • TLV 포맷 도입
  • Nonce 값으로 UUIDv7 사용
  • Additional Data 필드 추가

 

HMAC 생성 오류

논리적 오류는 HMAC 생성에 있었어요. 개념적으로 HMAC은 시크릿 키와 메시지로 해시 값을 만들어 메시지의 진위를 보장해요. 저희는 TOTP의 카운터와 HMAC으로 동적 인증 토큰을 만들었어요. 자세한 내용은 이전 글의 "동적 보안 토큰"을 확인해 주세요.

 

이전 사양에선 HMAC 키로 Auth token을 사용한다고 했어요. 하지만 서버는 Auth token의 Hash 값을 저장하기 때문에 서버에서 HMAC 값을 생성할 수 없는 오류가 있었어요. 간단하고 바보 같은 오류를 지금까지 놓치고 있었다는 게 참 어이가 없어요; 😑

 

새로운 HMAC 키를 선택해야 해요. 그리고 키는 결제 서버와 앱만 알고 있는 값이어야 해요. 생각해 보면 이미 AES 암호화에서 사용하고 있는 키가 있는데, 이 키를 HMAC에서도 사용해도 괜찮을까요? 만약 이 키를 쓴다면 HMAC을 활용하는 이유가 없어지게 돼요. AES 키를 알아내면 HMAC 역시 뚫리니 카운터 값을 쉽게 알아낼 수 있어요. 

새로운 키를 할당하는 건 어떨까요? 사용자 당 두 개의 키를 가지면 키 관리의 부담이 증가해요. 그리고 두 키는 로컬 토큰이라는 같은 맥락에서 사용되기 어떠한 이유로 키 하나가 유출되면 다른 키도 유출 같은 경로로 유출이 될 가능성이 커요. 이 방법은 문제를 해결하긴 하지만, 그만큼 보안 수준이 높아진다고 말할 수는 없을 것 같아요.

 

하나의 키만 사용하는데 오프라인 동적 인증과 안전한 키 관리를 함께 달성하려면 어떻게 해야 할까요?

 

HKDF

저는 HKDF로 접근해 봤어요. KDF(key derivation function)는 키 유도 함수예요. KDF를 사용하는 이유는 엔트로피가 낮은 키의 엔트로피를 높이고, 키의 길이를 늘이는 데 사용돼요. 파일 암호화 서비스를 예로 들어 볼게요. 만약 사용자가 입력한 비밀번호를 암호 키로 그대로 사용하면 (비교적) 금방 비밀번호를 알아낼 수 있을 거예요. 하지만 KDF를 사용하면 사용자 비밀번호로 더 강력하고 길게 만들 수 있어요. 당연히 유도된 키로 기존 키를 알아내는 건 불가능해요.

KDF

 

HKDF는 HMAC을 이용한 키 유도 함수예요. 제가 HKDF를 선택한 이유도 HMAC을 사용하기 때문이에요. 아이디어는 간단한데, 루트 키와 카운터로 동적 키를 유도하고, 이 키를 엄호화 페이로드에 사용하는 거예요.

그냥 해시 함수(cryptographic hash function)를 사용하지, 왜 HKDF를 사용하는지 궁금할 수도 있어요. HKDF의 내부 동작과 보안 등 더 자세한 내용이 궁금하다면 RFC5869HKDF-paper를 살펴보면 도움이 될 거예요. 저도 공부 중이에요:)

 

카운터가 키를 유도하는데 이용되기 때문에 시간이 지나면 키는 자동으로 만료돼요. 다시 말해 시간이 지나면 암호키를 알고 있어도  정확한 카운터 값을 구할 수 없기 때문에 암호화 필드를 한 번 더 보호할 수 있어요. 물론 루트 키와 충분한 시간이 있다면 복호화가 가능은 해요. 시간이 지나면 복호화도 거의 불가능하도록 만드는 게 제 목표예요.

 

오프라인 무차별 대응 공격에서도 완전히 자유로워졌어요. 어차피 현실적으로 불가능한 공격이지만, 특히 30초 안에 유도된 키를 찾고, 또 카운터 값과 루트 키를 알아내는 건 절대 불가능하죠.

 

또 다른 이점은 키를 확장할 수 있다는 점이에요. 저희는 AES-GCM에서 128 비트의 키를 사용했어요. 다른 키 길이를 사용하는 암호화 알고리즘을 사용하려면 전체 키를 마이그레이션 해야 해요. 그리고 그건 아주 번거로운 작업이에요. HKDF로 키 길이를 자유롭게 늘릴 수 있으니 키 길이에 대한 걱정도 없앨 수 있어요. 이는 바로 아래에서 소개할 새로운 암호화 알고리즘을 채택하는데도 큰 영향을 주었어요.

 

HKDF 들여보기

HKDF는 "추출 후 확장" 패러다임을 따라요. 첫 번째 단계인 추출은 흩어진 엔트로피를 짧지만 암호학적으로 강하게 집중시키는 데 사용돼요. 재료 키가 이미 충분한 엔트로피를 가졌다면 이 단계는 생략해도 괜찮아요.

"extract"stage is to "concentrate" the possibly dispersed entropy of the input keying material into a short, but cryptographically strong, pseudorandom key.

 

 추출 과정에서 만들어진 키는 PRK(pseudorandom key)라 하고, 재료 키와 salt의 HMAC 값으로 구해요. 신기한 건 HMAC의 salt 자리에 재료 키가 들어가고, 키 자리엔 salt가 들어간다는 점이에요.

 

PRK = HMAC-Hash(salt, IKM)

 

두 번째 단계인 확장(Expand)은 PRK와 선택적인 추가 정보(info)를 이용해서 255*HashLen 이하의 키(OMK)를 만들어요. 여기서 원하는 길이로 키를 늘릴 수 있어서 "확장"이라고 부르는 것 같아요. 알고리즘은 RFC5869#section-2.3에서 확인하실 수 있어요. 아주 간단해요!

 

salt는 "추출"단계에서 키를 준비하는 데 사용되고, info는 "확장"단계에서 키를 유도하는 데 사용된다는 것을 기억해 주세요.

 

HDKF 적용

저희는 해시함수로 sha384를 사용하고, salt는 사용자 식별자, info는 'local-generated-payment-token'과 c값을 연결한 값을 사용해요. c가 506440433이라면 info는 'local-generated-payment-token506440433'이에요.

 

다음에 소개할 XChaCha20-Poly1305 알고리즘은 32 바이트의 키와 24 바이트의 nonce를 필요로 하기 때문에 52 바이트로 OMK를 산출하여 앞 32 바이트는 키로 사용하고, 그 뒤의 24 바이트는 nonce로 사용해요.

 

의사코드는 다음과 같아요.

tmp = hkdf_sha384(
  len = 56,
  ikm = Rk,
  info = 'local-generated-payment-token' || c,
  salt = user_id
);

k = tmp[0:32]
n = tmp[32:]

 

Nonce 값도 HKDF로 유도되기 때문에 더 이상 토큰에 Nonce를 담지 안아도 돼요. 그래서 토큰의 길이를 16바이트나 줄일 수 있었고, 결과적으로 QR 코드 버전 5를 사용할 수 있게 됐어요. (이전 사양에선 버전 6을 사용했습니다.)

 

XChaCha20-Poly1305

이 요상한 이름을 가진 친구는 AES를 대신할 대칭키 AEAD 알고리즘이에요. XChacha20은 암호화 알고리즘이고, Poly1305는 16바이트 MAC을 생성하는 알고리즘이에요. XChaCha20은 AES와 달리 스트림 암호화(Stream cipher)예요.

 

AES-GCM 대신 이 알고리즘을 선택한 이유는 AES-GCM보다 더 빠르고 모바일 기기에서 적은 전력을 사용한다는 점 때문이었어요.. [1] 그리고 SSH, TLS v1.3, WireGuard, PASETO 등 ChaCha20-Poly1305를 사용하고 있는 사례가 많기도 해서 안전하게 선택해도 되겠다고 생각했어요.

 

이 알고리즘에 대한 자세한 설명과 동작 방법까진 제가 설명할 수 없어요. 관심 생겼다면 위키를 한 번 살펴보세요.😙

 

XChaCha20-Poly1305 적용

키와 nonce는 HKDF 단계에서 준비한 값으로 사용해요. 그리고 ad 값으로 메타데이터 페이로드와 일반 페이로드를 연결한 값을 사용해서 암호화되지 않은 두 필드의 진위도 보장할 수 있어요.

e = xchacha20_poly1305_encrypt(
  message = payload,
  key = k,
  nonce = n
  ad = metadata_payload || common_payload
);

 

TLV 포맷

TLV는 Tag-Length-Value를 뜻해요. 말 그대로 값의 유형을 나타내는 Tag, 값의 길이를 나타내는 Length, 그리고 값을 연결시켜 데이터를 나타내는 방법이에요. TLV는 EMV 뿐만 아니라 TLS, SSH 등에서 사용되고 있어요.

 

TLV를 사용하면 페이로드에 새로운 필드를 추가하기 쉽고, 파싱도 쉬워져요. 페이로드 안에서 TLV를 배치하는 순서는 상관없기 때문에 더 유연하게 페이로드를 확장할 수 있어요.

TLV 컨셉

 

사실 사양 초기부터 TLV를 사용하고 싶었어요. 하지만 그땐 토큰의 길이를 1 바이트라도 줄이는 게 더 중요했기 때문에 깊게 생각하지 않았어요. 그러나 이젠 토큰의 길이도 어느 정도 충분히 짧아졌기 때문에 이번 기회에 TLV를 도입하였어요.

 

그렇다고 TLV가 너무 많은 공간을 차지하도록 할 건 아니에요. 지금 사양을 보면 구분되어야 하는 값(사용자 식별자, 기기 식별자 등), 즉 태그는 10개 정도밖에 되지 않아요. 보통 태그와 길이를 각각 1~4바이트로 정보로 나타내는데, 태그를 나타낼 때 1바이트를 사용하는 것도 사치인 거죠. 1바이트는 256가지의 정보를 나타낼 수 있으니까요.

 

4비트로 타협을 볼 수 있어요. 총 16개의 태그를 이용할 수 있죠. 이것 만으로도 충분해요.

 

하지만 데이터는 바이트 단위로 처리하는 게 좋아요. 그럼 length는 4비트나 1.5바이트를 사용해야 해요. 그런데 1.5바이트를 쓸 바엔 차라리 태그 1바이트, 길이 1바이트를 사용하겠어요. 하지만 4비트는 16가지의 정보만 나타낼 수 있고, 값의 길이는 16 바이트보다 큰 경우가 훨씬 많아요. 어쩔 수 없이 4비트 태그를 포기해야 하는 걸까요?

 

1. 자주 사용되는 길이의 테이블을 만든다

한 가지 방법으로 자주 사용되는 값의 길이로 테이블을 만드는 거예요. 값의 크기는 주로 1 바이트, 2 바이트, 16바이트 등이고, 3 바이트나 7 바이트 같은 길이의 값은 사용되지 않는다고 봐도 무방해요. 그래서 이렇게 자주 나올 확률이 높은 길이의 표를 만들어 사용하는 거죠.

Index Length
0 1
1 2
3 16
2 24

 

2. 지수(exponent)로 사용한다.

처리되는 값들을 자세히 관찰하면 대부분 2의 제곱수로 나타낼 수 있다는 걸 알 수 있어요. 1 바이트는 2의 0승, 16 바이트는 2의 4승이에요. 2의 제곱 수 크기의 값만 사용해야 한다는 제약이 생기지만, 현실적으로는 큰 제약이 아니라는 걸 알 수 있어요. 그리고 더 직관적이고 수학적으로 계산이 가능해요. 그래서 저는 두 선택지 중 이 방법을 선택했어요.

 

 0111 0000 00001010   0001   0010  00000000 ... 0000   0001   0010  00000000 ... 0000
└──────  PLI  ─────┘ ├ tag ┘└ len ┤└      value      ┤ ├ tag ┘└ len ┤└      value     ┤
                     ├      TL    ┘                  │ ├     TL     ┘                 │
                     └────────────  TLV  ────────────┘ └────────────  TLV  ───────────┘
└─────────────────────────────────────  payload  ─────────────────────────────────────┘

 

사용 중인 태그와 TLV 포맷에 대한 자세한 구현 내용은 TLV.md에서 확인하실 수 있어요.

 

UUIDv7을 Nonce로 사용

이전 사양에선 nonce를 랜덤 값으로 사용하라고만 했었어요. 그런데 이번에는 반드시 UUIDv7을 사용하도록 업데이트했어요.

 

2023년에 표준이 된 UUIDv7은 48비트 Unix epoch와 랜덤 값으로 이루어져요. 사실 시간을 이용한 UUID는 이미 있었지만(v1, v6), 이들은 다른 기준시를 사용하고 있어요. unix epoch을 사용한다는 점에서 개발자들이 열광했었죠.

 

Nonce에 시각 정보가 있다는 점은 카운터가 업데이트되기 전 토큰의 생성 순서를 알 수 있게 해 줘요

"버려진 토큰" 시나리오를 생각해 볼게요.

 

버려진 토큰

  1. A가 첫 번째 토큰을 발급한다.
  2. B가 A의 첫 번째 토큰의 사진을 찍는다.
  3. A가 카운터가 업데이트되기 전, 두 번째 토큰을 발급한다.(앱 재실행, 다른 결제 수단 선택 등)
  4. A의 첫 번쨰 토큰은 "버려진 토큰"이다.
  5. A가 두 번째 토큰으로 결제를 한다.
  6. B가 A의 첫 번쨰 토큰으로 결제를 한다.    -->  부정 거래 발생!!

 

하지만 토큰이 시각 정보를 가지고 있으면 가장 최근 결제 시각 이전에 발급된 토큰을 거부하면 되기 때문에 위와 같은 버려진 토큰 문제를 해결할 수 있어요.

 


 

지금까지 로컬 토큰 0.4의 주요 변경 사항을 알려드렸어요. 이것뿐만 아니라 문서 분리, AD 필드 추가도 있지만, 중요한 내용은 아니라 생략했어요. 업데이트된 새로운 사양의 모습이 궁금하다면 local-generated-payment-token에서 확인하실 수 있어요.

 

이제 정말 인터넷 없는 결제를 이루기에 시간이 얼마 남지 않은 것 같아 기뻐요.😆