이 실무 가이드는 하드웨어 보안 모듈(Hardware Security Module, HSM)과 스마트카드에 접근하는 표준 인터페이스 PKCS#11을 설명하고, 여러 제조사의 구현을 다룰 때 자주 생기는 오류와 대응법을 정리한다. 특히 세션과 스레드 관리, 처리량에 맞는 세션 수, 객체 속성 및 수명 설정처럼 실제 구현과 운영에 영향을 주는 문제를 다룬다.

가이드를 만든 이유는 두 가지다. 하나는 PKCS#11 구현을 위한 실용적인 가이드가 드물다는 점이고, 다른 하나는 제작사 i2km이 PKCS#11 관련 소프트웨어를 판매한다는 사실을 투명하게 밝히고 제품의 배경을 설명하기 위해서다. 이 글의 문제 사례는 PKCS#11을 작성하고 디버깅하며 얻은 경험을 바탕으로 한다. 예상 읽기 시간은 20분이며, 시뮬레이터 코드를 포함한 작성에는 약 2일이 걸렸다고 밝힌다.

PKCS#11과 HSM의 역할

PKCS#11은 공개키 암호 표준 11호(Public Key Cryptography Standard number 11)의 약칭이다. HSM과 스마트카드 안에 있는 암호 객체를 만들고, 수정하고, 삭제하거나 암호 연산을 수행하는 C 인터페이스를 정의한다. 이 가이드는 주로 HSM을 다루지만, 설명의 상당 부분은 스마트카드에도 적용된다.

일반적으로 클라이언트 애플리케이션은 PKCS#11 모듈을 통해 HSM에 요청한다. 모듈과 HSM 사이에서는 제조사별 독점 인터페이스와 프로토콜이 쓰인다. 제조사는 보통 PKCS#11 모듈을 제공하지만, 성능을 위해 표준 인터페이스를 우회하고 자체 프로토콜을 직접 쓰는 경우도 있다. OpenSSL이나 Java JCE에서 HSM을 사용하는 경우에도 대개 내부적으로 C 기반 PKCS#11 모듈이 동작한다. Microsoft CNG는 별도의 인터페이스다.

HSM이 필요한 이유는 외부 공격자뿐 아니라 내부자에게서 키를 보호하고, 키가 어떻게 사용됐는지와 복제본이 어디에 있는지를 확실히 파악하기 위해서다. 강한 암호 알고리즘을 선택해도 실제 환경에서는 부채널 공격이나 키 복사·탈취가 발생할 수 있다. 예를 들어 내부 직원이 키를 USB 드라이브에 복사해 부정한 송금에 사용할 수 있다. 이런 위협에는 접근 제어와 물리적 격리가 필요하다.

HSM은 키를 장치 안에서 생성하고 외부로 내보내지 않도록 하는 방식으로 이를 뒷받침한다. 접근 제어 기능은 누가 키를 사용하는지 기록하고, 소프트웨어를 통한 키 추출 경로가 없도록 해야 한다. 다만 실제 구현에는 예외와 세부 조건이 있다. 물리적 공격에도 완벽히 면역인 것은 아니다. 예를 들어 장치를 액체 질소로 냉각해 메모리에서 키를 복구하려는 콜드 부트 공격이나 TEMPEST 방출 공격 등이 가능성으로 거론된다.

HSM은 범용 제품과 결제용 제품으로 나뉜다. 결제용 HSM은 범용 HSM과 같은 하드웨어를 쓰기도 하지만 프로토콜은 다를 수 있다. 이 가이드는 대체로 PKCS#11을 지원하는 범용 HSM에 초점을 둔다. HSM은 보통 PCI 카드나 표준 1U 랙 서버 형태이며, 물리적 침입을 막는 보호 구조로 에폭시에 묻힌 미세 금속망 등을 쓸 수 있다.

HSM으로 부르려면 하드웨어가 관련 기준을 충족해야 하며, 주요 기준인 FIPS 140-3 인증은 비용이 많이 든다. 키 관리자, HSM을 신뢰 기반으로 삼는 키 관리자, Intel SGX 같은 기밀 컴퓨팅 엔클레이브, SoftHSM, 엔클레이브 안의 소프트웨어 HSM, 임계값 암호를 HSM과 같은 것으로 볼 수 있는지 등은 혼동하기 쉬운 사례다. 가이드는 이 가운데 클라우드 HSM을 제외하면 해당하지 않는다고 설명한다. 처리량이 높고 능동적 보호 기능을 갖춘 엔터프라이즈 HSM인지 가늠하는 냉소적인 기준으로 3만 달러 미만인지(클라우드라면 연간 비용인지)를 들지만, 이는 엄밀한 판별 기준은 아니다.

PKCS#11은 HSM 내부의 객체를 관리하고 사용하도록 클라이언트에 제공하는 API다. 키 생성, 데이터 암호화, 인증서 서명, 키 파생 등을 요청할 수 있으며, 연산 전에 일정 수준의 인증과 권한 확인을 수행하는 기능도 포함한다.

표준 버전과 제조사별 차이

PKCS#11은 1994~1995년 RSA Security에서 다른 PKCS 표준들과 함께 시작됐다. v2.20은 2004년, OASIS가 기술위원회를 만든 뒤의 v2.40은 2015년에 나왔다. 큰 버전 변경은 2020년 v2.40에서 3.0으로 넘어가며 이뤄졌고, 3.2는 2026년에 출시됐다. 핵심 규약과 함수, 패턴은 1995년 이후 대체로 유지됐으며, 지난 30년간의 주요 변화는 알고리즘과 멀티스레딩 등에 집중됐다.

버전 채택은 제조사마다 다르다. 가장 큰 제조사인 Thales도 Luna HSM의 PKCS#11 인터페이스에 2004년 버전인 v2.20을 사용한다. 다른 제조사도 최신 버전 도입 단계가 서로 다르며, 표준보다 채택이 늦는 데에는 이유가 있다고 가이드는 덧붙인다. 제조사 입장에서는 최신 버전의 기능이 필요하지 않을 수 있고, HSM 업계가 보수적이기도 하다.

PKCS#11은 기본 구조와 일부 역할·패턴·알고리즘·규약을 정하지만, 그 밖에는 제조사별 확장이 많다. 제조사들은 핵심 표준을 대체로 유지하면서 알고리즘과 역할, 암호 기능을 추가해 왔다. 공통 기반을 유지하면서 확장을 허용하는 편이 현실적인 타협이라는 설명이다. 제조사들은 손쉽게 다른 업체의 HSM으로 바꿀 수 있는 상황을 원하지 않지만, 공통 프로토콜은 기술 인력과 인증 측면에서 이점이 있다.

표준 v2.40에는 함수 68개가 정의돼 있지만, 현재 Thales Luna PKCS#11 모듈에는 제조사 전용 확장이 299개 있다. Entrust는 PKCS#11을 nCore API의 래퍼로 취급해 이에 해당하는 기능을 PKCS#11 확장 대신 nCore에서 제공한다. Utimaco CryptoServer도 PKCS#11을 독점 API의 래퍼로 다루는 비슷한 방식이다. 따라서 핵심 표준은 공통 기능과 규약을 제공하지만, 각 제조사는 그 밖에 상당한 확장을 갖는다. 상용 모듈이 표준의 핵심 함수 100개 미만을 모두 준수하는 것도 일반적이지 않으며, 상용 준수 검사 도구를 판매하려는 시도도 있었다.

키를 만들 때 필요한 속성 조합처럼 개별 함수 수준에서도 제조사별 차이가 있다. 같은 코드를 한 모듈에서는 실행할 수 있어도 다른 모듈에서는 필수 속성이 달라 이식되지 않을 수 있다. 따라서 PKCS#11은 공통 기반이지만 다양한 구현과 독점 확장이 함께 존재하는 표준이다.

구현에서 자주 생기는 문제

PKCS#11의 기본 용어부터 짚어야 한다. 슬롯과 토큰은 물리적 스마트카드나 교체 가능한 저장 장치가 슬롯에 꽂히던 사용 방식에서 비롯된 역사적 용어다. HSM에는 물리적 슬롯과 토큰이 하나뿐이어도 인터페이스가 여러 슬롯을 보여줄 수 있다. 제조사에 따라 여러 논리 슬롯·파티션을 제공하며, Thales/Gemalto Luna의 파티션은 대체로 논리 토큰에 대응한다. 슬롯과 토큰의 관계가 고정인지 동적인지도 제조사마다 다를 수 있다.

세션은 요청이 수행되는 상태 있는 문맥으로, 민감한 작업 전에 열고 인증하는 경우가 많다. 세션은 유한한 기간과 상태를 가지며, 한 세션에서 쓴 객체나 핸들은 다른 세션으로 옮기거나 재사용할 수 없다. 핸들은 세션이나 객체, 예를 들어 키를 가리키는 포인터처럼 볼 수 있다. 같은 키도 세션에 따라 핸들이 12 또는 812로 다르게 표시될 수 있고, 한 세션의 핸들 12가 다른 세션에서는 AES 키를 가리킬 수도 있다. 세션이 바뀌면 핸들을 재사용하지 말고 새 세션에서 객체를 다시 검색해야 한다.

스레드와 세션을 섞지 말 것. PKCS#11 v1.0(1995)은 멀티스레드 애플리케이션이나 뮤텍스를 다루지 않았다. 단일 스레드 프로그램에는 해당하지 않지만, 멀티스레드 프로그램에서는 하나의 세션을 여러 스레드가 동시에 사용해서는 안 된다. 세션과 스레드를 일대일로 대응시키는 것이 원칙이다. 세션은 HSM의 상태 기계에 대응하는 직렬 처리 객체로 볼 수 있다. 한 스레드가 C_EncryptUpdate를 호출하는 동안 다른 스레드가 같은 세션에서 C_EncryptFinal을 호출하는 등 순서가 정의되지 않는 상황을 피해야 한다. C_OpenSession 호출 때 CKF_SERIAL_SESSION 플래그를 설정하는 것도 세션의 직렬 특성을 보여준다.

모듈은 세션 밖에 있는 내부 상태를 동기화할 수 있어야 한다. 애플리케이션은 C_Initialize를 호출할 때 다음 플래그로 모듈에 사용할 스레드 처리 방식을 알릴 수 있다.

  • CKF_LIBRARY_CANT_CREATE_OS_THREADS
  • CKF_OS_LOCKING_OK

Go에서는 고루틴이 여러 운영체제 스레드에 배치되므로 주의가 필요하다. 고루틴이 실행 중인 스레드 사이를 옮겨 다니고 PKCS#11 모듈이 스레드 로컬 저장소(TLS)에 세션 정보를 보관하면, 세션이 다른 스레드의 TLS를 전달받을 수 있다. 그 결과 데이터 손상, 세그멘테이션 오류 또는 충돌이 발생할 수 있다. 흔한 Go-to-C PKCS#11 래퍼를 사용할 때 드물지만 실제로 발생할 수 있고 진단하기 어렵다. 세션을 쓰는 동안 고루틴을 특정 운영체제 스레드에 고정하는 방법은 다음과 같다.

runtime.LockOSThread()
 defer runtime.UnlockOSThread()
 // Open session and perform session work

세션 수는 처리량과 지연 시간에 맞춰야 한다. HSM을 하루나 한 시간에 몇 번만 사용해 루트 키 역할, 간헐적 서명 또는 데이터베이스 루트 키 언랩을 수행한다면 세션 하나를 열고 로그인한 뒤 암호 연산을 하고 로그아웃한 다음 닫는 방식으로 충분할 수 있다. 대량의 요청을 처리하거나 지연 시간에 민감하다면 이미 로그인한 세션을 포함해 세션 풀을 운용할 수 있다. 단순 계산에서는 전체 처리량이 세션 수와 세션당 초당 처리량의 곱이지만, 실제 처리량은 네트워크 시간과 HSM 병렬성 등 여러 요인에 따라 달라진다.

가이드에 나온 계산기 예시는 클라이언트와 HSM 사이의 핑 시간 0.1ms, 연산당 HSM 처리 시간 2ms, HSM 처리 유닛 8개, 동시 세션 1개를 입력값으로 제시한다. 이때 이론상 초당 최대 연산 수는 16,000회, 실제 최대 연산 수는 12회로 표시된다. 이는 네트워크 시간과 HSM 병렬성을 고려한 근사 모델이며, 실제 성능 수치와 잘 맞는다고 제작 측은 설명한다.

각 세션은 직렬로 동작하므로 초당 처리 가능한 연산 수에 한계가 있다. 세션 하나가 초당 100회 처리할 수 있는데 초당 1,000회를 처리해야 한다면 세션을 늘려 부하를 나눠야 한다. 적정 수는 HSM까지의 네트워크 지연, HSM의 연산 시간, 클라이언트 RAM 사용량, HSM RAM 사용량에 좌우된다. HSM이 가까워 핑 시간이 거의 없다면 클라이언트나 HSM 중 코어 수가 더 적은 쪽에 맞춰 세션 수를 정하는 것이 좋다. 네트워크 지연이 몇 밀리초를 넘으면 HSM의 세션 처리기가 클라이언트 요청을 기다리는 시간이 길어지므로, 유휴 시간이 줄어들 때까지 세션을 늘릴 수 있다. 반대로 지연이 거의 없는 16코어 환경에서 세션 1,000개를 열면 클라이언트와 HSM에서 문맥 전환과 메모리 사용이 커져, 세션 16개를 사용할 때보다 처리량이 낮아질 수 있다. 가이드는 HSM까지의 왕복 지연이 가장 중요한 요소이며, 트래픽 양상에 맞춰 세션 수를 정하고 지나치게 많이 열지 말라고 권한다.

토큰과 슬롯의 대응 관계가 고정돼 있다고 가정하지 말아야 한다. C_GetSlotList를 두 번 호출했을 때 첫 번째에는 슬롯 2, 9, 13, 14가, 두 번째에는 2, 8, 9, 11, 14, 16이 반환되는 식으로 목록이 달라질 수 있다. 일부 제조사는 일관성을 유지하지만 다른 구현에서는 그렇지 않으며, 기저 모듈 문제의 징후일 수도 있다. 특히 클라우드 HSM에서 토큰이 몇 초 전 슬롯 5에 있었다고 해서 다시 조회해도 슬롯 5에 있다고 볼 수 없다. 항상 다시 확인해야 한다.

네트워크 지연은 여러 번 누적될 수 있다. PKCS#11 함수 한 번이 HSM에 메시지 한 번만 보낸다고 단정할 수 없다. 단일 함수 호출이 클라이언트와 네트워크 HSM 사이에서 순차 왕복을 약 40회 일으킨 사례도 있었다. 네트워크 거리가 몇 밀리초를 넘으면 지연이 눈에 띄게 커지고 문제가 될 수 있다.

클러스터, 이중화 그룹, 부하 분산, 장애 조치는 PKCS#11 표준이 다루지 않는다. 제조사마다 처리 방식이 다르며, 설정·시험·복원력이 실제 운영에서 신뢰성 문제를 일으키는 큰 원인이다. 가이드는 이 주제를 더 깊이 다루지는 않는다.

객체 검색은 페이지가 끝날 때까지 반복해야 한다. 검색 템플릿을 설정하고 C_FindObjectsInit으로 준비한 뒤, C_FindObjects를 호출해 한 번에 처리할 최대 객체 수를 지정한다. 모듈은 일치한 객체와 반환 개수를 알려준다. 반환된 수가 0보다 크면 다음 페이지가 있을 수 있으므로 C_FindObjects를 반복하고, 더는 객체가 없어 0이 반환되면 C_FindObjectsFinal로 정리한다. 한 번만 호출하고 검색을 끝내면 페이지 한도를 넘는 객체를 찾지 못해 애플리케이션에서 사용할 수 없게 될 수 있다. 페이지 크기가 작으면 HSM 왕복 횟수도 늘어난다.

키 생성 속성은 모듈마다 요구사항이 다르다. 어떤 모듈은 CKA_* 속성을 받아들이지만 다른 모듈은 CKR_TEMPLATE_INCOMPLETE, CKR_ARGUMENTS_BAD 또는 CKR_TEMPLATE_INCONSISTENT를 반환할 수 있다. 오류가 무엇이 빠졌거나 맞지 않는지 자세히 알려주지 않는 경우도 있다. 해당 객체에 필요한 속성을 제조사 문서에서 확인해야 하며, 미들웨어가 제조사별 템플릿을 정리해 주는 경우도 있지만 이식성 문제는 남는다. 해결은 흔히 시행착오에 의존하고, 운이 좋으면 HSM 오류 로그에서 단서를 찾을 수 있다. 템플릿에 속성이 빠지면 HSM이 기본값을 적용할 수 있지만, 기본값도 제조사마다 달라 사전에 예측하기 어렵다. 템플릿 속성을 가능한 한 빠짐없이 명시해 HSM이 결정할 값을 줄이는 것이 좋다.

CKA_ID와 CKA_LABEL은 객체와 키를 식별하는 데 쓰이지만, 보통 고유성이 보장되지 않는다. 같은 값을 가진 객체 10개를 만들 수도 있다. 예컨대 CKA_ID가 같은 대칭 키 두 개를 만들고 하나로 암호화한 뒤 그 ID로 검색하면 다른 키가 선택돼 복호화에 실패할 수 있다. CKA_ID는 공개키와 개인키처럼 연관된 객체를 묶는 데도 쓰인다. Luna의 CKA_OUID처럼 일부 제조사는 더 고유한 식별자를 제공한다. 검색 템플릿이 여러 객체와 일치하는 경우도 처리해야 한다. 첫 번째 결과가 원하는 객체라고 무조건 가정하면 혼란이나 복호화·검증 실패가 생길 수 있다.

PKCS#11 v3.0 이전에는 초기화 벡터(initialization vector, IV) 같은 암호학적으로 민감한 매개변수를 클라이언트가 만들지 HSM이 만들지 표준이 정하지 않았다. 대부분의 경우 고유성 요건을 충족하도록 HSM이 직접 생성하는 편이 낫다. FIPS 140-3은 HSM에서 생성한 IV 사용을 강하게 권장하므로, FIPS 모드 HSM이 클라이언트가 제공한 IV를 거부하는 경우가 있다. 비준수 모드에서는 동작하던 코드가 FIPS 모드로 전환한 뒤 암호화에 실패할 수 있다. HSM이 IV를 생성할 때 애플리케이션에 돌려주는 방식도 표준화돼 있지 않아, 암호문 앞이나 뒤에 붙이는 등 제조사별 차이가 있다.

PKCS#11 v3.0의 C_MessageEncrypt*는 AEAD를 원래 AEAD용이 아닌 함수에 끼워 넣던 상황을 개선했다. 하지만 v3.0 이상 준수 수준이 고르지 않아 AEAD 처리는 여전히 이식성이 특히 낮은 영역일 수 있다.

세션 유형과 객체 수명도 구분해야 한다. 세션은 읽기 전용 또는 읽기·쓰기 모드로 연다. 읽기 전용 세션에서는 암호 연산과 객체 읽기가 가능하지만 토큰 객체를 만들거나 수정하거나 삭제할 수 없다. 대신 일시적인 세션 객체는 만들 수 있다. 읽기·쓰기 세션에서는 토큰 객체를 만들고 수정하고 삭제할 수 있다.

키와 인증서 같은 객체는 토큰 객체 또는 세션 객체다. 세션을 열고 로그인한 뒤 키를 만들었는데 새 세션에서 키를 찾지 못한다면, 생성할 때 CKA_TOKEN=true를 지정하지 않았을 수 있다. 이 경우 일시적인 세션 객체가 만들어져 세션이 닫힐 때 자동으로 삭제된다. CKA_PRIVATE=true인 토큰 객체는 인증하지 않은 세션에 보이지 않으며, C_Login에 성공해야 검색 결과에 나타난다. 읽기·쓰기 세션이라고 해서 로그인된 상태인 것은 아니다. 세션은 읽기·쓰기일 수 있으면서도 인증되지 않을 수 있다.

세션이 끝난 뒤에도 키를 보존하려면 일반적으로 읽기·쓰기 세션을 쓰고 객체에 CKA_TOKEN=true를 설정해야 한다. 로그인한 사용자만 키를 볼 수 있게 하려면 CKA_PRIVATE=true를 지정한다. 이를 설정하지 않으면 인증되지 않은 세션도 C_FindObjects로 키를 찾을 수 있다. 명세상 CKA_PRIVATE 키 객체를 로그인 없이 암호 연산에 사용하는 것은 금지된다.

CKA_MODIFIABLE 설정도 중요하다. 애플리케이션은 나중에 CKA_LABEL과 CKA_ID 조합으로 키를 다시 찾는 경우가 많다. 토큰 키가 CKA_PRIVATE=false이고 CKA_MODIFIABLE=true라면, 시스템의 다른 인증된 일반 사용자 애플리케이션이 키를 찾아 속성을 바꿀 수 있다. 다른 프로세스가 실수로 또는 악의적으로 CKA_LABEL을 바꾸면 기본 애플리케이션이 다음 시작 때 키를 찾지 못할 수 있다. 생성 후 바꿀 필요가 없는 객체라면 CKA_MODIFIABLE=false를 명시해야 한다.

이 속성의 기본값은 제조사마다 달라 한 곳은 true, 다른 곳은 false를 기본값으로 쓸 수 있다. 토큰 기본값에 의존하지 말고 객체를 만들 때 수명주기 템플릿을 명시하는 것이 좋다. 민감한 키라면 일반적으로 CKA_MODIFIABLE=false, CKA_TOKEN=true, CKA_PRIVATE=true를 설정한다. CKA_SENSITIVE와 CKA_EXTRACTABLE은 가이드 2부에서 다룬다.

가이드 2부와 KeyMirror

이어지는 2부에서는 호출 규약과 널 포인터, 문자열 종료 문자, 연산 진행 상태, PIN의 실제 역할과 기저 보안, 로그인 실패, 한 번 또는 여러 번 로그인하기, 권한, 잠금과 뮤텍스, 콜백, PKCS#11을 HTTP/REST 뒤에 두는 문제를 다룰 예정이다.

제작사 i2km은 KeyMirror도 소개한다. PKCS#11은 인터페이스일 뿐이며, 구현이 완벽해도 네트워크와 장치 지연, 처리량과 용량, 세션 사이의 장치 경합, 애플리케이션 충돌로 남는 세션 등 운영 문제가 남을 수 있다. PKCS#11은 HSM의 운영 상태나 지연 시간 추세, 특정 클라이언트의 세션이 문제를 일으키는지 알려주지 않는다. 종단 간 모니터링과 추적, 분석에는 별도 도구가 필요하다는 것이 제작사의 설명이다.

i2km은 대형 HSM 제조사에서 일한 경험을 바탕으로 PKCS#11의 모니터링 한계와 OpenTelemetry·OpenMetrics 같은 클라우드 네이티브 형식 사이의 차이를 확인했다고 말한다. PKCS#11 애플리케이션에서 동작, 처리량, 지연 시간, 비정상적인 키 사용 양상을 실시간으로 계측하려면 노력과 비용이 많이 들어 이를 시도한 조직이 드물다는 설명이다. 이에 따라 관측 가능성이 PKCS#11 인터페이스에서 끝나고 그 이후는 보이지 않는 경우가 많으며, 일부 감사 로그를 수개월 뒤 포렌식에 사용하는 정도일 수 있다고 덧붙인다.

KeyMirror는 클라이언트 애플리케이션과 제조사 모듈 사이의 PKCS#11 명령을 관찰하고, 이를 텔레메트리와 추적으로 변환하는 모니터링 계층이다. Prometheus, DataDog, Grafana 같은 도구에 PKCS#11 애플리케이션의 데이터를 연결할 수 있다고 제작사는 설명한다. 애플리케이션별 지연 시간, 클라이언트별 트래픽, 실패율, HSM 용량 문제의 조기 경고를 확인하고 애플리케이션 장애 해결 시간을 몇 시간이나 며칠에서 몇 분으로 줄일 수 있다는 것도 제작사의 주장이다.

배포에는 몇 분이 걸리며 일반적인 배포에서는 5~10분 안에 실시간 결과를 볼 수 있다고 한다. 분석 엔진은 텔레메트리·추적·지표 외에도 문제가 있거나 잘못 구현·설정된 애플리케이션을 찾아 구현 오류와 병목 해결에 관한 애플리케이션별 안내를 제공할 수 있다. KeyMirror는 제조사에 구애받지 않으면서 제조사별 확장을 지원하며, i2km은 Thales, Entrust, Utimaco의 기술 파트너라고 밝힌다.