ID TECH
문의
기술 게시물 전체 보기

기술 포스트

JavaScript에서 TLV를 파싱하는 방법

칩 카드(ICC)의 장점 중 하나는, 카드에서 출력되는 데이터가 사실상 항상 BER-TLV라는 표준 형식으로 제공된다는 점입니다. 쉽게 말하면 '기본 인코딩 규칙, 태그-길이-값(Tag-Length-Value)'을 의미하며, 이에 대한 유익한 읽을거리는 여기).

BER-TLV 형식은 ITU X.690에서 정의된 ASN.1(추상 구문 표기법) 인코딩 방식 중 하나로, 인터넷 태동기로 거슬러 올라가는 매우 오래된 표준 체계입니다.

칩 카드는 카드 데이터를 인코딩하기 위해 TLV 방식을 사용합니다. 가장 단순하게 설명하면, 태그-길이-값 방식은 예를 들어 "5A"라는 태그의 값이 8옥텟이고 그 내용이 "41 11 12 34 56 78 9A BC"와 같은 16진수 값으로 이루어진 경우, TLV 인코딩 결과는 5A084111123456789ABC가 됩니다. 여기서 5A는 태그, 08은 길이, 4111123456789ABC는 값입니다.

EMVCo(칩 카드 기술을 주도하는 카드 발급사 컨소시엄)는 칩 카드 거래를 위한 다양한 표준 태그를 정의하고 있습니다. 예를 들어 5A는 항상 PAN(주 계좌 번호 또는 카드 번호)을 인코딩하고, 9F02는 거래 승인 금액을, 5F2D는 언어 환경 설정을 인코딩하는 식입니다. EMVCo가 정의한 태그 전체 목록과 각 태그의 의미는 아래 링크에서 확인할 수 있습니다. https://www.eftlab.co.uk/index.php/site-map/knowledge-base/145-emv-nfc-tags.

TLV는 데이터 길이 정보를 자체적으로 포함하고 있으니, TLV 데이터를 파싱하는 것은 간단할 것 같지 않나요?

뭐, 그렇습니다. 대체로는요. 어느 정도는요.

모든 태그가 1바이트 식별자(예: 5A)로만 구성된다면, TLV 스트림 파싱은 정말 식은 죽 먹기일 것입니다. 하지만 식별자가 256가지 값밖에 가질 수 없다면 TLV 방식은 그다지 유용하지 않을 것입니다.

태그 식별자의 확장성을 확보하기 위해, 기본 인코딩 규칙(Basic Encoding Rules)은 다중 바이트 태그의 사용을 허용합니다. 규칙에 따르면, 첫 번째 태그 바이트의 하위 5비트가 모두 설정되어 있을 경우 추가 태그 식별자 바이트가 뒤따릅니다. 이후 바이트에서는 다음 바이트가 존재할 경우 최상위 비트가 1로 설정되며, 마지막 바이트에서는 최상위 비트가 0이 됩니다. 예를 들어, 5F24는 유효한 2바이트 태그 식별자이고, DFEF01은 유효한 3바이트 태그이며, 이와 같은 방식으로 확장됩니다.

EMVCo(EMV 규격 Book 3, Annex B에서 BER-TLV를 인용하고 있음)는 TLV 간의 계층적 부모-자식 관계(중첩 구조)를 구현하기 위해 "래퍼(wrapper)" 태그 개념도 허용합니다. EMV 규칙에 따르면, 태그 첫 번째 바이트의 여섯 번째 비트가 설정된 경우 해당 태그는 "구성형(constructed)"으로 간주됩니다(필자는 복합형(compound)이라는 용어를 선호합니다). 따라서 3바이트 태그 FFEE01은 가상의 TLV인 3F0188과 3F025544를 다음과 같이 감쌀 수 있습니다: FFEE01073F01883F025544. 부모 태그 FFEE01은 3바이트 TLV 하나와 4바이트 TLV 하나로 구성된 총 7바이트의 데이터를 포함합니다. 이 방식을 활용하면 태그 그룹을 원하는 깊이만큼 중첩할 수 있습니다.

주의해야 할 점은, TLV의 Length 바이트 역시 다중 바이트가 될 수 있다는 것입니다. 이에 대한 확장 규칙(EMV Book 3 Annex B2에서 발췌)은 다음과 같습니다:

최상위 비트가 설정된 Length 바이트는 하위 7비트를 "Length의 길이"로 해석해야 함을 의미합니다. 즉, Length 바이트 값이 0x82이면 이어지는 두 바이트에 실제 Length 정보가 담겨 있음을 뜻합니다. 가상의 TLV인 5F0F8103AABBCC를 예로 들면, 태그는 5F0F이고, Length의 길이는 1바이트, 실제 Length는 3바이트, 그리고 Value는 AABBCC입니다.

명확하게 이해되시나요?

이 모든 내용을 바탕으로, 약 75줄의 JavaScript 코드로 완전한 범용 재귀 하향 TLV 파서를 다음과 같이 구현할 수 있습니다.

// 'data'는 "95050010203000…" 등과 같은 형식이어야 합니다.

// 즉, 직렬화된 TLV들이 하나의 큰 문자열로 이어진 형태입니다.

// TLV 객체가 반환됩니다. 이를 사용하여 태그 이름으로 Value를 조회할 수 있습니다.

// TLV['95']에는 태그 95의 값이 저장됩니다.

// TLV['9F26']에는 태그 9F26의 값이 저장되는 방식입니다.

여기서 사용하는 방식은 매우 단순합니다:

먼저, 모든 알려진 EMVCo(산업 표준) 태그와 ID TECH 전용 태그를 포함하는 태그 식별자 사전을 준비합니다. 이 사전을 _KnownTags라고 하며, '5A'와 같은 식별자가 존재하는지 확인하려면 _KnownTags[ '5A' ] 의 반환값이 true인지 확인하면 됩니다.

다음 단계: 파싱!

파싱 알고리즘은 매우 간단합니다:

한 번에 두 개의 니블(nibble)을 tag 변수에 저장하고, 해당 태그가 딕셔너리에 존재하는지 확인합니다. 딕셔너리의 모든 태그는 1바이트, 2바이트, 또는 3바이트 길이이므로, 6개의 니블을 읽었음에도 알려진 태그를 찾지 못한 경우에는 읽기 프레임을 2니블 앞으로 이동하고 아무 일도 없었던 것처럼 계속 진행합니다(단, "태그가 예상되었으나 발견되지 않았습니다"라는 콘솔 메시지를 출력한 후). 원한다면 예외를 발생시키는 방식을 선택할 수도 있지만, 저의 철학은 파서가 기본적으로 장애 허용(fault tolerant) 방식으로 동작해야 한다는 것입니다. 물론 상황에 따라 다르겠지만, 파싱된 나머지 데이터를 계속 활용해야 할 경우를 대비해서입니다.

태그를 찾은 후에는 작업자 메서드, 여기서는 readData()라는 내부 함수를 사용하여 태그를 읽고 지나가고, Length를 읽은 다음, 해당 Length를 이용해 Value를 읽습니다. (이 단계에서는 추정 Length의 최상위 비트를 반드시 확인하여, 앞서 언급한 Length-of-the-Length 확장 규칙을 적용해야 하는지 여부를 판단해야 합니다.)

Value를 태그를 조회 키로 사용하여 스토리지 객체에 저장합니다. tag.

마지막으로 스토리지 객체를 반환합니다.

그럼 실제 사례를 살펴보겠습니다. 예를 들어 ID TECH Augusta 칩 카드 리더기를 키보드 모드로 사용하여 QuickChip 데이터를 캡처한다고 가정해 보겠습니다. 카드를 삽입했을 때 장치에서 출력되는 데이터는 다음과 같은 형태일 수 있습니다.

이 데이터는 DFEE25라는 ID TECH 전용 태그로 시작하는 대용량 TLV 데이터 블록입니다. (ID TECH 태그의 의미에 대한 자세한 내용은 ID TECH TLV 태그 참조 가이드 를 다운로드하여 확인할 수 있습니다. https://idtechproducts.atlassian.net/wiki/spaces/KB/overview) 이 블록에 포함된 대부분의 태그는 업계 표준 EMVCo 태그입니다. 이 블록을 문자열로 JS 변수 tagblock에 할당한 후, 위의 파서를 불러와 parseTags( tagblock )를 실행하면 다음과 같이 태그와 값으로 구성된 객체를 반환받게 됩니다.

이 태그들 중 일부는 비어 있습니다. 9F27과 같이 값이 00인 것도 있고, 암호화된 것도 있습니다. 그러나 기본적으로 EMV 거래를 처리하는 데 필요한 모든 태그가 바로 여기에 있습니다.

TLV 파싱에 왜 JavaScript를 사용하는가? 그 진짜 이유를 말씀드리면 재미가 없어질 수 있으니, 지금은 살짝만 힌트를 드리겠습니다. 결제 앱 환경에서 Node.js 를 활용하는 방법, JavaScript를 이용해 신용카드 단말기와 통신하는 방법, Servlet과 AJAX를 활용해 백엔드 테스트 서버에 요청을 보내는 방법 등 흥미로운 주제들이 곧 이 블로그에서 다루어질 예정입니다. 지금 바로 북마크해 두시고, 곧 다시 방문해 주세요!