💡 전체 작동 예제는 GitHub에서 확인할 수 있습니다: vault-transit-pdf-signing-python

자동 서명이 키 보관 검토에 실패하는 이유

컴플라이언스 및 재무 운영 팀은 수천 건의 문서에 서명합니다: 대출 계약서, 명세서, 정책 팩, 감사 서한 등. 거의 대부분은 사람이 버튼을 클릭해 서명하는 것이 아니라 서비스가 일정에 따라 수행합니다. 해당 문서에 첨부된 인증서는 조직의 이름을 담고 있습니다. 이 때문에 서명 키는 회사가 보유한 가장 민감한 자산 중 하나이며, 일반적으로 이를 사용하는 애플리케이션 옆에 있는 PFX 파일에 저장됩니다.

외부 서명은 Python용 GroupDocs.Signature 기능으로, .NET을 통해 문서 다이제스트를 사용자가 제공하는 코드로 전달하고 애플리케이션이 보유한 개인 키로 서명하지 않습니다. 이는 PFX 방식이 검증을 견디지 못하기 때문에 존재합니다. 파일은 복사본이며, 복사본은 컨테이너 이미지, 배포 파이프라인, 백업, 지난 봄에 프로덕션 이슈를 디버깅한 개발자 노트북 등으로 퍼집니다. 이러한 복사본과 비밀번호를 가진 사람은 누군가가 발견하고 인증서를 폐기할 때까지 귀하의 이름으로 서명할 수 있습니다.

이 팀들이 필요로 하는 것은 다른 파일 형식이 아니라, 키가 정확히 한 곳에 존재하고 사용될 때마다 감사 로그가 남으며, 키 자체를 신뢰하지 않고도 서명을 요청할 수 있는 서명 서비스입니다. 이것이 HashiCorp Vault와 같은 키 관리자의 역할이며, 문서 라이브러리가 암호화를 위임하도록 설정될 때만 도움이 됩니다.

비밀번호로 보호된 인증서 파일만으로는 충분하지 않은 이유

본능적으로 PFX를 잠그려 합니다: 파일 권한을 제한하고, 비밀번호를 비밀 관리자에 저장하고, 볼륨을 암호화합니다. 각 단계가 도움이 되지만 핵심 사실은 변하지 않습니다. 서명 시점에 개인 키가 프로세스에 로드되므로 메모리 덤프, 컨테이너 탈출, 부주의한 로그 문구 등으로도 노출될 수 있습니다. 원하는 제어는 읽을 수 없는 키이며, 읽기 어려운 키가 아닙니다.

Vault의 Transit 엔진이 이를 제공합니다. 암호화를 서비스 형태로 수행합니다: 값을 보내면 Vault가 보유한 키로 서명하거나 암호화하고 결과를 반환합니다. 내부에서 생성된 키는 내보낼 수 없도록 표시할 수 있으며, 받은 데이터를 절대 저장하지 않습니다. GroupDocs.Signature는 다른 절반을 담당하는데, DigitalSignOptions.custom_sign_hash를 통해 사용자가 직접 서명자를 PDF 서명 파이프라인에 연결할 수 있습니다.

설정: Vault Transit와 함께 사용하는 Python용 GroupDocs.Signature

GroupDocs.Signature는 암호화 전까지 모든 작업을 수행합니다. PDF를 열고, 서명이 적용되는 바이트 범위를 결정한 뒤 해당 범위를 해시하고, 다이제스트와 함께 사용자의 서명자를 호출합니다. 사용자의 서명자는 다이제스트를 Vault에 전달하고 Vault가 생성한 바이트를 반환합니다. 라이브러리는 이를 인증서와 함께 삽입하여 일반적인 서명된 PDF를 작성합니다.

Python 3.11 또는 3.13, groupdocs-signature-net 및 asn1crypto 패키지, 그리고 개발 모드 Vault 컨테이너를 위한 Docker가 필요합니다. GroupDocs 라이선스는 선택 사항이며, 없을 경우 평가 모드에서 최대 2페이지 문서를 허용합니다. 샘플 저장소는 두 개의 명령으로 시작부터 끝까지 실행되며, 두 개의 환경 변수를 변경하면 동일한 코드가 프로덕션 Vault를 가리키게 됩니다.

단계별 구축

단계 1 - Vault에서 서명 키 읽기

애플리케이션은 사용이 허가된 키를 Vault에 요청하고, 버전 번호와 공개 키를 반환받습니다. 개인 키는 절대 응답에 포함되지 않습니다.

key = vault.read_key(KEY_NAME)
if key is None:
    # First run: enable Transit and create an RSA-2048 key.
    vault.enable_transit()
    vault.create_key(KEY_NAME, "rsa-2048")
    key = vault.read_key(KEY_NAME)
print(f"Signing key: '{KEY_NAME}', version {key.version}")

버전을 한 번 읽는 것이 이 단계의 목적입니다. 인증서는 하나의 공개 키를 보유하므로, 인증서와 모든 서명은 동일한 버전에서 나와야 합니다.

단계 2 - 내보낼 수 없는 키를 위한 인증서 생성

PDF 서명은 검증자가 공개 키를 찾을 수 있도록 X.509 인증서를 포함합니다. 내보낼 수 없는 키의 경우, 인증서는 공개 키를 중심으로 구성되고 Vault에 의해 서명됩니다.

tbs_certificate = x509.TbsCertificate({...})   # built around the Vault public key

# Hash locally, then have Vault sign the certificate's own digest.
digest = hashlib.sha256(tbs_certificate.dump()).digest()
signature = vault.sign_hash(key_name, key.version, digest, "sha2-256")

certificate = x509.Certificate({
    "tbs_certificate": tbs_certificate,
    "signature_algorithm": algorithm,
    "signature_value": signature,
})

인증서는 의도적으로 현재보다 하루 앞서 시작됩니다. PDF 리더는 서명 시간을 시작 날짜와 비교하며, UTC보다 두 시간 동쪽에 있는 머신에서는 서명 직전 발행된 인증서가 Adobe Acrobat에 의해 만료되었거나 아직 유효하지 않다고 보고됩니다.

단계 3 - 외부 서명자를 통해 PDF 서명

두 줄은 Python에 특화된 내용입니다. 바인딩에는 X.509 타입이 없으므로 인증서 객체는 DigitalVerifyOptions에서 반환됩니다; DigitalSignOptions에 경로를 전달하면 “Specified certificate doesn’t contain private key” 오류가 발생합니다. 또한 sign_time은 의도적으로 시간대 정보를 포함하는데, 이는 오프셋이 있는 값이 PDF에 기록되기 때문입니다.

certificate_object = DigitalVerifyOptions(
    certificate_stream=io.BytesIO(certificate)).certificate
digital_signature = DigitalSignature(certificate=certificate_object)
digital_signature.sign_time = datetime.now().astimezone()

options = DigitalSignOptions()
options.signature = digital_signature
options.custom_sign_hash = VaultSigner(vault, KEY_NAME, key.version)
options.hash_algorithm = HashAlgorithm.SHA256
options.reason = "Document signed with HashiCorp Vault Transit"

with gs.Signature(str(input_pdf)) as signature:
    signature.sign(str(output_pdf), options)

서명자 내부에서는 다이제스트를 prehashed = true와 pkcs1v15와 함께 Vault에 보냅니다. 이 두 필드는 서명의 유효성을 결정하며, 첫 번째 필드가 없을 경우 조용히 실패합니다.

단계 4 - 서비스에서 나가기 전에 검증

options = DigitalVerifyOptions(certificate_stream=io.BytesIO(certificate))
with gs.Signature(str(signed_pdf)) as signature:
    is_valid = signature.verify(options).is_valid

같은 실행에서 서명과 검증을 수행하면 조용히 실패하는 모드가 즉시 실패로 전환됩니다. 문서 파이프라인에서는 이것이 관문 역할을 합니다: 검증에 실패한 파일은 아카이브, 거래 상대방, 고객 포털에 절대 도달하지 않습니다.

비즈니스 영향: 감사에 어떤 변화가 있는가

우려 사항 애플리케이션 호스트의 PFX Vault Transit를 통한 서명
개인 키 복사본 호스트, 이미지, 백업 및 노트북당 하나 하나, Vault 내부
서명 앱이 필요로 하는 권한 키 파일에 대한 전체 접근 키를 읽고 transit/sign 호출
문서가 서명되었음을 증명 애플리케이션 로그(추가된 경우) 서명당 하나의 Vault 감사 항목
유출 의심 시 대응 폐기, 재발급, 전역 재배포 Vault에서 키 회전, 한 번 재발급
누가 귀하를 대신해 서명할 수 있는가 파일과 비밀번호를 가진 사람 유효한 Vault 토큰을 가진 사람

마지막 행이 솔직한 답변입니다. 이 설계는 비밀을 제거하는 것이 아니라 이동시키는 것입니다: 도난당한 Vault 토큰도 여전히 서명을 요청할 수 있습니다. 차이점은 토큰이 단기간 유효하고, 한 곳에서 폐기 가능하며, 두 가지 작업에만 범위가 제한되고, 모든 사용이 로그에 기록된다는 점입니다. 이는 복사본을 모두 파악할 수 없는 파일보다 검토 시 방어하기 훨씬 쉽습니다.

실제 워크플로우: 서비스가 서명한 대출 계약서

대출 플랫폼은 신청이 승인되면 계약서 PDF를 생성하고 서명한 뒤 고객의 문서 보관소에 저장합니다. 이 변경 전에는 서명 서비스가 회사 인증서를 보유하고 배포 매뉴얼에 새 호스트에 PFX를 복사하는 단계가 있었습니다. 변경 후에는 서비스가 두 가지 권한을 가진 AppRole 토큰을 보유하며, 인증서의 개인 키는 Vault 외에는 존재하지 않았습니다.

감사자가 지난 분기에 몇 개의 문서가 서명되었는지, 어떤 방식으로 서명되었는지 물으면, 답변은 애플리케이션 로그를 재구성하는 것이 아니라 Vault의 감사 장치에서 나옵니다. 팀이 키를 회전할 때는 한 곳에서 회전하고 새로운 버전에 대한 새 인증서를 발급합니다.

먼저 무엇을 정확히 해야 할까?

다이제스트 처리입니다. 잘못된 알고리즘 이름은 500 crypto/rsa: input must be hashed message와 함께 크게 실패하며, 1분 정도 걸립니다. prehashed 플래그가 누락되면 오류가 전혀 발생하지 않으며, Vault가 다이제스트를 다시 해시하고 서명이 잘못된 값을 포함해 PDF가 완성된 것처럼 보입니다. 서명하는 모든 파일을 검증하여 이러한 실패가 보이지 않게 해야 합니다.

이 설계가 적용되는 다른 영역

이 훅은 뒤에 무엇이 있는지 알지 못하므로 동일한 네 단계가 하드웨어 보안 모듈, 클라우드 KMS 또는 스마트 카드에서도 작동합니다. 나는 이 빌드를 .NET 형제에서 포팅했으며 서명자가 재작업이 필요할 것으로 예상했지만 실제로 변경된 부분은 인증서 전달뿐이었습니다; Vault 요청 본문과 버전 고정은 동일했습니다. 의료, 정부, 보험 분야 팀도 재무와 같은 이유로 이를 사용합니다: 키는 사용된 목적을 증명할 수 있는 곳에 있어야 합니다.

시작하기

리소스