ID TECH
기술 게시물 전체 보기

기술 포스트

JavaScript에서 TLV를 파싱하는 방법

칩 카드(ICC)의 장점 중 하나는, 카드에서 출력되는 데이터가 BER-TLV라는 표준 형식으로 제공된다는 점입니다. 풀어서 설명하면, Basic Encoding Rules, Tag-Length-Value의 약자로(이에 관한 흥미롭고 유익한 글을 여기).

BER-TLV 형식은 ITU X.690에서 정의한 ASN.1(Abstract Syntax Notation) 인코딩 방식 중 하나로, 인터넷 여명기로 거슬러 올라가는 매우 오래된 표준 체계입니다.

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

칩 카드 전반을 관장하는 카드 발급사 컨소시엄인 EMVCo는 칩 카드 거래에 사용되는 표준 태그를 다수 정의하고 있습니다. 예를 들어 5A는 항상 PAN(Primary Account Number, 즉 카드 번호)을 인코딩하며, 9F02는 거래 승인 금액, 5F2D는 언어 환경설정 등을 인코딩합니다. EMVCo가 정의한 태그의 전체 목록과 각 태그의 의미는 https://www.eftlab.co.uk/index.php/site-map/knowledge-base/145-emv-nfc-tags.

TLV는 데이터 길이 정보를 자체적으로 인코딩하므로, TLV 데이터를 파싱하는 것은 매우 간단할 것 같지 않으신가요?

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

모든 태그가 5A처럼 1바이트 식별자를 사용한다면, TLV 스트림 파싱은 정말 매우 간단할 것입니다. 그러나 식별자가 256개의 값만 가질 수 있다면 TLV 방식은 그다지 유용하지 않을 것입니다.

태그 식별자의 확장성을 확보하기 위해, 기본 인코딩 규칙(Basic Encoding Rules)은 다중 바이트 태그의 사용을 허용합니다. 이 규칙에 따르면, 첫 번째 태그 바이트의 하위 5비트가 모두 1로 설정된 경우 이후에 태그 식별자 바이트가 더 존재함을 의미합니다. 이어지는 바이트에서는 최상위 비트가 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의 데이터는 총 7바이트로, 3바이트 TLV 하나와 4바이트 TLV 하나로 구성됩니다. 이 방식을 활용하면 태그 그룹을 원하는 깊이만큼 중첩할 수 있습니다.

한 가지 주의할 점은, TLV의 Length 바이트도 다중 바이트로 구성될 수 있다는 것입니다. 확장성 규칙(EMV Book 3 Annex B2 기준)은 다음과 같습니다:

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

다소 복잡하게 느껴지실 수 있습니다.

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

// '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개의 니블만큼 앞으로 이동한 후 아무 일도 없었던 것처럼 계속 진행합니다(단, "태그가 필요한 위치에서 태그를 찾지 못했습니다"라는 콘솔 메시지를 출력한 후). 원한다면 이 시점에서 예외를 발생시킬 수도 있지만, 제 철학은 파서가 기본적으로 페일-소프트(결함 허용) 방식으로 동작해야 한다는 것입니다(물론 상황에 따라 다르지만). 파싱된 데이터의 나머지 부분을 계속 활용해야 할 경우를 대비해서입니다.

태그가 발견되면, 워커 메서드(여기서는 readData()라는 내부 함수)를 사용하여 태그를 지나쳐 읽고, Length를 읽은 다음, 그 Length 값을 이용해 Value를 읽습니다. (여기서는 추정되는 Length의 최상위 비트를 반드시 확인하여, 앞서 언급한 Length의 확장성 처리 규칙을 따를 필요가 있는지 판단해야 합니다.)

Value를 태그의 조회 키를 기준으로 저장 객체에 저장합니다. tag.

마지막으로 저장 객체를 반환합니다.

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

이것은 ID TECH 고유 태그인 DFEE25로 시작하는 대용량 TLV 데이터 블록입니다. (ID TECH의 태그가 의미하는 바에 대해 더 자세히 알고 싶다면, ID TECH TLV Tag Reference Guide 를 다음 링크에서 다운로드하시기 바랍니다. https://idtechproducts.atlassian.net/wiki/spaces/KB/overview.) 그러나 이 블록에 포함된 태그의 대부분은 업계 표준 EMVCo 태그입니다. 이 블록을 문자열로 JS 변수 tagblock에 할당한 후, 위의 파서를 불러와 parseTags( tagblock )를 실행하면, 다음과 같이 태그와 값으로 구성된 객체가 반환됩니다.

일부 태그는 비어 있고, 일부(예: 9F27)는 값이 00입니다. 일부는 암호화되어 있습니다. 하지만 기본적으로 EMV 거래를 실행하는 데 필요한 모든 태그가 여기에 있습니다.

TLV 파싱에 JavaScript를 사용하는 이유는 무엇일까요? 솔직히 말씀드리자면, 지금 당장 그 이유를 밝히면 여러분이 느끼고 있을 기대감을 깨뜨릴 수 있습니다. 결제 앱 환경에서 Node.js 를 활용하는 방법, JavaScript로 신용카드 리더기와 통신하는 방법, Servlet과 AJAX를 이용해 백엔드 테스트 서버와 연동하는 방법 등을 곧 소개할 예정이니까요. 이 모든 내용이 조만간 이 블로그에 게재될 예정입니다. 지금 바로 북마크하고 다시 방문해 주세요!