X.509 인증서 파싱하기

X.509 인증서의 파싱 방법과 C 언어로 구현한 예제이다.


PKI(Public Key Infrastructure)에서 사용되는 X.509 인증서는 ASN.1(Abstract Syntax Notation One) 표기법으로 작성되어 있다. ASN.1은 크로스 플랫폼 간에도 데이터를 올바르게 전송할 수 있는 데이터 구조를 정의한 형식이다.
이번 글에서는 X.509 인증서의 (ASN.1 형식으로 구성됨) 구조와 이를 파싱하여 원하는 항목을 얻는 간단한 C 예제를 구현해 본다.

ASN.1 참고 자료

ASN.1 길이

ASN.1 길이 표기법은 다음 표와 같이 요약할 수 있다.

길이 1st byte 2nd byte 3rd byte 4th byte 5th byte 값의 범위
1byte 0x00 ~ 0x7F - 0x00 ~ 0x7F
2bytes 0x81 0x00 ~ 0xFF - 0x00 ~ 0xFF
3bytes 0x82 0x0000 ~ 0xFFFF - 0x0000 ~ 0xFFFF
4bytes 0x83 0x000000 ~ 0xFFFFFF - 0x000000 ~ 0xFFFFFF
5bytes 0x84 0x00000000 ~ 0xFFFFFFFF 0x00000000 ~ 0xFFFFFFFF

Tool을 사용해서 ASN.1 파싱하기

Online 툴

Windows에서 ASN.1 파싱

Windows에서는 기본으로 설치된 certutil 툴을 이용할 수 있다. 아래와 같이 실행하면 입력 파일을 ASN.1 형식으로 파싱 해서 출력해 준다.

C:\>certutil -asn <입력 파일>

만약에 입력 파일이 X.509 인증서인 경우에는 아래와 같이 실행하면 인증서로 파싱해 준다. (사실상 인증서 파일을 더블 클릭하면 표시되는 인증서 팝업 내용과 동일함)

C:\>certutil -dump <입력 파일>

Linux에서 ASN.1 파싱

Linux에서는 OpenSSL 툴에서 asn1parse 명령을 이용할 수 있다. 아래와 같이 실행하면 도움말이 출력된다.

$ openssl help asn1parse

아래 예와 같이 실행하면 입력 파일을 ASN.1 형식으로 파싱 해서 출력해 준다.

$ openssl asn1parse -inform DER -in <입력 파일>

OpenSSL을 이용한 X.509 인증서 파싱

OpenSSL을 이용해서 X.509 인증서를 파싱하는 것은 OpenSSL을 그대로 이용하면 되므로 간단하다. 이런 경우라면 Parsing X.509 Certificates with OpenSSL and C 등을 참고하면 쉽게 구현할 수 있다.

OBJECT_ID 인코딩

ASN.1에서는 오브젝트를 구분하기 위하여 OBJECT_ID가 사용되는데 (각 ID들은 이미 정의되어 있음), 이 값은 길이가 가변이고 ASN.1에 명시된 가변 길이 표기법을 따라서 인코딩 된다.
즉, OBJECT_ID 필드는 x.x.x.x.x… 와 같은 여러 개의 노드로 구성되어 있으며 아래와 같은 규칙으로 인코딩 된다.

  • 1번째, 2번째 node 값은 (1번째 node * 40 + 2번째 node)의 1바이트로 인코딩 됨
  • node 값이 127 이하인 경우에는 그대로 1바이트 값으로 인코딩 됨
  • node 값이 128 이상인 경우에는 ASN.1 포맷에 따라 다수의 바이트로 인코딩 됨

아래에 예를 들어본다.

  1. OBJECT_ID = 2.5.4.3 인 경우
    1번째 node, 2번째 node: 2*40 + 5 = 0x55
    3번째 node: 4 => 0x04
    4번째 node: 3 => 0x03
    => 최종 인코딩 결과: 0x55 0x04 0x03
  2. OBJECT_ID = 1.3.6.1.4.1.311.21.20 인 경우
    1번째 node, 2번째 node: 1*40 + 3 = 43 = 0x2B
    3번째 node: 6 => 0x06
    4번째 node: 1 => 0x01
    5번째 node: 4 => 0x04
    6번째 node: 1 => 0x01
    7번째 node: 311 = 0x137 => 0x82 0x37
    8번째 node: 21 => 0x15
    9번째 node: 20 => 0x14
    => 최종 인코딩 결과: 0x2B 0x06 0x01 0x04 0x01 0x82 0x37 0x15 0x14
  3. OBJECT_ID = 1.0.8571.2.1 인 경우
    1번째 node, 2번째 node: 1*40 + 0 = 40 = 0x28
    3번째 node 8571 = 0x217B => 0xC2 0x7B
    4번째 node 2 => 0x02
    5번째 node 1 => 0x01
    => 최종 인코딩 결과: 0x28 0xC2 0x7B 0x02 0x01

X.509 인증서 형식

  1. X.509 v3의 디지털 인증서는 ASN.1 형식으로 아래 구조로 구성된다.
    • Version: 인증서의 버전을 나타냄
    • Serial Number: CA가 할당한 정수로 된 고유 번호
    • Signature: 서명 알고리즘 식별자
    • Issuer: 발행자
    • Validity: 유효기간
      • Not Before: 유효기간 시작 날짜
      • Not After: 유효기간 끝나는 날짜
    • Subject: 소유자
    • Subject Public Key Info: 소유자 공개 키 정보
    • Public Key Algorithm: 공개 키 알고리즘
    • Subject Public Key: 소유자의 공개 키
    • Issuer Unique Identifier: (Optional) 발행자 고유 식별자
    • Subject Unique Identifier: (Optional) 소유자 고유 식별자
    • Extensions: (Optional) 확장

    위 내용을 Extension을 제외한 필수 정보만 표로 다시 나타내면 아래와 같다.

    항목명 설명
    Version 인증서의 버전
    SerialNumber 인증서 고유의 일련 번호
    Signature 발급자의 서명
    Issuer 발급자의 정보. DN(distinguished name) 형식
    Validity 인증서의 유효 기간 (시작 날짜와 종료 날짜)
    Subject 주체의 정보. DN(distinguished name) 형식
    SubjectPublicKeyInfo 주체의 공개키
  2. 위에서 Issuer(발급자)와 Subject(주체)의 DN(distinguished name) 형식은 아래와 같다.

    항목명 설명 DN 항목 이름
    countryName 2자리 국가 코드 C
    stateOrProvinceName 주(도) 이름 ST
    localityName 시 이름 L
    organizationName 소속 기관명 O
    organizationalUnitName 소속 부서명 OU
    commonName 주체를 나타낼 수 있는 이름 CN
    emailAddress 이메일 주소 emailAddress

아래 C 구현 예에서는 X.509 인증서에서 주체(subject)의 commonName을 추출을 해 볼텐데, 위에서 볼 수 있듯이 이 정보는 Subject의 commonName이다.
따라서 commonName의 OBJECT_ID는 2.5.4.3이므로, 2번째 OBJECT_ID 값이 2.5.4.3인 항목을 찾아서, 이어지는 PRINTABLE_STRING 문자열을 얻으면 된다.

C로 파싱 구현 예

아래는 X.509 인증서에서 subject의 commonName를 추출해서 출력하는 내가 작성한 예제 코드이다. 코드에 간단히 주석을 달았으므로, 자세한 코드 설명과 전체 코드는 생략한다.

/* ASN.1 타입 정의 */
typedef enum
{
    ASN1_TYPE_RESERVED = 0x00,
    ASN1_TYPE_BOOLEAN = 0x01,
    ASN1_TYPE_INTEGER = 0x02,
    ASN1_TYPE_BIT_STRING = 0x03,
    ASN1_TYPE_OCTET_STRING = 0x04,
    ASN1_TYPE_NULL = 0x05,
    ASN1_TYPE_OBJECT_IDENTIFIER = 0x06,
    ASN1_TYPE_OBJECT_DESCRIPTOR = 0x07,
    ASN1_TYPE_INSTANCE_OF = 0x08,
    ASN1_TYPE_REAL = 0x09,
    ASN1_TYPE_ENUMERATED = 0x0A,
    ASN1_TYPE_EMBEDDED_PDV = 0x0B,
    ASN1_TYPE_UTF8_STRING = 0x0C,
    ASN1_TYPE_RELATIVE = 0x0D,
    ASN1_TYPE_SEQUENCE_SEQUENCE_OF = 0x10,
    ASN1_TYPE_SET_SET_OF = 0x11,
    ASN1_TYPE_NUMERIC_STRING = 0x12,
    ASN1_TYPE_PRINTABLE_STRING = 0x13,
    ASN1_TYPE_TELETEX_STRING = 0x14,
    ASN1_TYPE_VIDEOTEX_STRING = 0x15,
    ASN1_TYPE_IA5STRING = 0x16,
    ASN1_TYPE_UTC_TIME = 0x17,
    ASN1_TYPE_GENERALIZED_TIME = 0x18,
    ASN1_TYPE_GRAPHIC_STRING = 0x19,
    ASN1_TYPE_VISIBLE_STRING = 0x1A,
    ASN1_TYPE_GENERAL_STRING = 0x1B,
    ASN1_TYPE_UNIVERSAL_STRING = 0x1C,
    ASN1_TYPE_CHARACTER_STRING = 0x1D,
    ASN1_TYPE_BMP_STRING = 0x1E
} ASN_1_TYPE_E;

/* 입력 ASN.1 길이를 얻고, 길이 필드의 길이를 리턴한다. */
unsigned int get_asn1_length(const void *buf, unsigned int *length)
{
    unsigned char *p = (unsigned char *)buf;
    unsigned char firstByte = *p++;

    if (firstByte >= 0x00 && firstByte <= 0x7F)
    {
        *length = firstByte;
        return 1;
    }
    else if (firstByte == 0x81)
    {
        *length = p[0];
        return 2;
    }
    else if (firstByte == 0x82)
    {
        *length = (unsigned short)(p[0] << 8) | p[1];
        return 3;
    }
    else if (firstByte == 0x83)
    {
        *length = (unsigned int)(p[0] << 16) | (p[1] << 8) | p[2];
        return 4;
    }

    *length = 0;
    return 0;
}

void print_common_name(unsigned char *certData, unsigned int certLen)
{
    unsigned char tag;
    unsigned char commonName_objectId[] = {0x55, 0x04, 0x03}; // CommonName OBJECT_ID = 2.5.4.3
    unsigned int length_field_len, length, i;
    bool constructed;
    unsigned int commonName_objectId_counter = 0;
    unsigned char *subjectCommonName;

    i = 0;
    while (i < certLen)
    {
        tag = certData[i];
        if (tag & 0x20)
        {
            constructed = true; // constructed
        }
        else
        {
            constructed = false; // primitive
        }
        tag &= 0x1F;
        i++;

        length_field_len = get_asn1_length(certData + i, &length);
        i += length_field_len;

        /* Constructed 이면 다음 TLV를 읽는다. */
        if (constructed == true)
        {
            continue;
        }

        /* CommonName OBJECT_ID 이면, 다음 PRINTABLE_STRING 정보를 얻는다. */
        if (tag == ASN1_TYPE_OBJECT_IDENTIFIER && length == sizeof(commonName_objectId))
        {
            if (memcmp(certData + i, commonName_objectId, sizeof(commonName_objectId)) == 0)
            {
                commonName_objectId_counter++; // 1st = for Issuer, 2nd = for Subject
            }
        }
        else if (tag == ASN1_TYPE_PRINTABLE_STRING)
        {
            if (commonName_objectId_counter == 2) // 2nd = for Subject
            {
                subjectCommonName = malloc(length);
                memcpy(subjectCommonName, certData + i, length);
                subjectCommonName[length] = '\0';
                printf("Subject commonName: %s\n", subjectCommonName);
                break;
            }
        }
        i += length; // 다음 TLV를 읽음
    }
}

맺음말

사실 나는 X.509 인증서가 ASN.1 형식으로 구성되어 있음은 예전에 PKI를 공부하면서 알고 있었는데, 최근에 회사 코드에서 X.509 인증서에서 파싱을 제대로 하지 않고 멋대로 고정 위치에서 정보를 읽어오는 C 코드를 별견하여서 (심지어 이게 상용 제품의 코드였음 😰), 이를 올바르게 파싱하여 얻는 방법으로 바로 잡으면서 블로그에 정리까지 해 보았다.

카테고리:

업데이트: