
LLM이 PDF 서명의 '진위 여부'를 검증할 수 있는 MCP 서버를 만들었다
요약
PDF의 암호학적 검증을 수행하는 MCP 서버인 pdf-verify-mcp를 소개합니다. 기존 reader 도구가 구조를 해석했다면, 이 도구는 변조 여부와 서명의 진위성을 판정합니다.
핵심 포인트
- PDF 서명의 암호학적 검증(cryptographic verification) 기능 제공
- ByteRange 재계산을 통한 데이터 변조 탐지 가능
- 서명 구조 해석과 진위성 검증의 역할 분담
- 신뢰할 수 있는 CA 인증서를 통한 서명자 신원 검증 지원
서론
지금까지의 기사에서, PDF 사양서를 '찾아보는' pdf-spec-mcp와 PDF의 내부 구조를 '읽는' pdf-reader-mcp를 소개했습니다.
pdf-reader-mcp에는 inspect_signatures라는 서명 필드의 구조를 해석하는 도구가 있습니다. /ByteRange나 /SubFilter, 서명 이유를 추출할 수 있습니다. 하지만 지난 기사를 작성했을 시점부터 해결하지 못한 질문이 하나 있었습니다.
"그래서, 이 서명은 유효한가?"
구조가 올바르게 보이더라도, 서명 후에 내용이 수정되었을지도 모릅니다. 인증서가 만료되었을지도 모릅니다. 애초에 누구나 만들 수 있는 자가 서명 인증서(self-signed certificate)일지도 모릅니다. 이것들은 구조를 살펴보는 것만으로는 판정할 수 없으며, **암호학적 검증 (cryptographic verification)**이 필요합니다.
그래서 만든 것이 pdf-verify-mcp입니다.
pdf-verify-mcp란
PDF의 **진위성 (authenticity)**을 검증하는 MCP 서버입니다. "내용에 무엇이 있는가" (reader), "사양은 무엇을 요구하는가" (spec)에 이어, PDF family의 세 번째 층인 "그것은 진짜인가"에 답합니다.
┌─────────────────────────────────────────┐
│ AI Agent (Claude 등) │
│ "이 계약서, 서명 후에 변조되지 않았어?" │
...
pdf-reader-mcp의 inspect_signatures와의 차이점
| 비교 항목 | pdf-reader-mcp inspect_signatures | pdf-verify-mcp verify_signatures |
|---|---|---|
| 서명 필드 구조 (ByteRange 등) | ✅ | ✅ |
| 다이제스트 대조 (변조 탐지) | ❌ | ✅ ByteRange를 재계산하여 CMS와 대조 |
| ... |
역할 분담은 명확하며, reader는 "구조", verify는 "진위성"입니다. 기존 inspect_signatures 설명문에 써두었던 "유효성 판정은 검증 도구의 영역"이라는 숙제를 구현을 통해 해결한 형태입니다.
5가지 도구
verify_signatures — 서명의 암호학적 검증
- 핵심 도구입니다. 실제로 서명된 PDF를 검증한 결과 (발췌):
## 1. Signature1
- Verdict: **VALID**
- Trust: **not_evaluated** — No trust anchors provided
...
동일한 PDF의 내용을 단 1바이트라도 수정하면 다음과 같이 됩니다.
- Verdict: **INVALID**
- Digest match (ByteRange vs messageDigest): **no**
- Note: ByteRange digest does not match the CMS messageDigest
...
"valid"는 "신뢰해도 좋다"가 아니다
이 부분이 이 도구에서 가장 전달하고 싶은 내용입니다. 위 예시의 verdict는 VALID이지만, Trust는 not_evaluated입니다. 즉, "서명 시점부터 바이트 열이 변하지 않았다"는 것은 증명되지만, 누가 서명했는지는 아무것도 보장되지 않습니다 (실제로 이 서명자는 자가 서명 테스트 인증서입니다).
서명자의 신원까지 검증하려면 신뢰하는 CA 인증서를 전달합니다.
verify_signatures({
file_path: "/path/to/contract.pdf",
trust_anchors: ["/path/to/ca-cert.pem"],
...
check_revocation: "online"을 지정하면, 인증서의 AIA 확장(extension)을 통해 OCSP 응답자(responder)에게 문의하고, CRL 배포 지점(distribution point)도 조회합니다. 중간 인증서(intermediate certificate)가 PDF에 포함되어 있지 않은 경우에도, AIA caIssuers를 따라 체인을 보완합니다 (오래된 서명된 청구서 등에서 실제로 자주 발생하는 케이스입니다).
verify_integrity — 서명 후의 변조 탐지
- PDF는 「증분 업데이트 (Incremental Update)」라는 메커니즘을 통해, 기존 바이트 열을 유지하면서 파일 끝에 변경 사항을 추가할 수 있습니다. 서명 후의 추가는 합법적(연서 또는 LTV 데이터 추가)이지만, 변경이 일절 금지된 (DocMDP P=1) 인증된 문서에 대한 추가는 인증 위반입니다.
인증 후 증분 업데이트를 가한 PDF의 실행 결과:
## Certification (DocMDP)
- Permission: 1 — No changes permitted
- Violated by later changes: **yes**
...
"서명은 암호학적으로 유효하다. 하지만 인증은 깨졌다"— 이 두 가지를 구분하여 보고할 수 있는 것이 핵심입니다.
detect_pades_level
— PAdES 베이스라인 레벨 판정
- 장기 서명 (LTV) 관점에서, 서명이 B-B / B-T / B-LT / B-LTA 중 어느 레벨인지 판정합니다.
- PAdES: yes — level **B-B**
- Evidence: signature timestamp=no, DSS=no, VRI=no, document timestamp=no
B-B 수준에 그치는 서명은 서명자 인증서가 만료되거나 유효 기간이 지나는 시점에 검증이 불가능해집니다. 10년 보관이 필요한 문서에서 이를 발견했다면, LTV화 (B-LT / B-LTA)가 필요하다는 신호입니다.
단순히 DSS 딕셔너리의 유무를 보는 것뿐만 아니라, DSS 내의 폐기 정보가 실제로 서명자 인증서를 커버하고 있는지까지 검증합니다. "선언상으로는 B-LT이지만 실제 데이터가 누락된" 파일을 B-T로 강등시킵니다.
identify_conformance
— PDF/A · PDF/UA 선언 식별
- XMP 메타데이터로부터 PDF/A (pdfaid)와 PDF/UA (pdfuaid) 선언을 읽어옵니다. 이는 어디까지나 "선언"의 식별이며, 실제로 적합한지는 다음 도구의 역할입니다.
validate_conformance
— PDF/A 적합성 검증 (하이브리드 엔진)
- "PDF/A-2b라고 선언한 PDF가 실제로 적합한가"를 검증합니다. 실행 결과:
- Flavour: PDF/A-2b
- Engine: verapdf
- Result: **NOT COMPLIANT**
...
엔진은 2단계 구조로 되어 있습니다.
| 엔진 | 조건 | 결과 처리 |
|---|---|---|
| veraPDF | 설치되어 있다면 자동으로 감지하여 위임 | 권위적 (compliant: true/false) |
| 내장 규칙 | veraPDF가 없을 때 | ~15개의 고가치 규칙 서브셋. 위반 발견은 확정적이지만, "위반 없음"이 인증을 의미하지는 않음 (compliant: null) |
내장 엔진이 "모든 규칙 통과"를 true가 아니라 null로 반환하는 것은 의도적인 설계입니다. 서브셋 검사만으로 "적합"을 자처하는 것은 정직하지 않기 때문입니다.
기술적인 설계 판단
ByteRange 다이제스트의 독립 검증
PDF 서명의 기본 구조는 "/Contents (서명 데이터)를 제외한 파일 전체의 해시에 서명하는 것"입니다.
|←── ByteRange[0,1] ──→|←─ /Contents ─→|←── ByteRange[2,3] ──→|
[ 서명 대상 바이트 열 전반 ][ 서명 데이터 ][ 서명 대상 바이트 열 후반 ]
pdf-verify-mcp는 pkijs의 검증 API에 모든 것을 맡기지 않고, ByteRange의 바이트 열로부터 다이제스트 (Digest)를 직접 재계산하여 CMS의 messageDigest 속성과 독립적으로 대조합니다. "서명 검증이 실패했다"는 사실만으로는 그것이 변조인지 형식이 맞지 않는 것인지 알 수 없지만, 다이제스트 대조를 분리해 두면 "서명 대상 바이트 열 자체가 바뀌었다 (=변조 가능성)"를 특정할 수 있습니다. verdict가 invalid / indeterminate를 구분할 수 있는 것은 이 분리 덕분입니다.
레거시 서명에 대한 현실적 대응 — MD5 및 OpenSSL 3
오래된 서명된 PDF(실제로 존재하는 모 기업의 청구서 등)에는 MD5 기반 서명이 남아 있습니다. WebCrypto는 MD5를 지원하지 않기 때문에 pkijs의 검증 경로를 타지 않습니다. 그렇다고 "미지원"이라며 거절하면 실제 문서를 검증할 수 없으므로, MD5/SHA-1 계열은 node:crypto로 폴백(fallback)하며, 검증 결과에 취약한 다이제스트(digest)라는 경고를 반드시 첨부합니다.
암호화된 PDF도 마찬가지입니다. RC4 암호화(R2–R4)를 사용하는 오래된 PDF는 여전히 대량으로 존재하지만, OpenSSL 3은 RC4를 비활성화했기 때문에 RC4는 순수 JS로 구현했습니다. AES-256(R6)의 키 유도(Key Derivation, ISO 32000-2 Algorithm 2.A/2.B)를 포함하여, 권한 암호화(Permission Encryption) PDF는 빈 비밀번호로 자동 복호화하며, 리더 비밀번호(Reader Password)가 있는 경우 password 파라미터로 복호화할 수 있습니다.
참고로 서명의 /Contents는 암호화 대상이 아니므로(ISO 32000-1 §7.6.2), 복호화에 실패하더라도 서명 검증 자체는 정상적으로 작동합니다. 복호화는 서명 사유나 필드 이름 등의 메타데이터를 복구하기 위한 것입니다. 잘못된 비밀번호는 /U 엔트리와의 대조를 통해 감지하며, 글자가 깨진 메타데이터를 반환하지 않도록 처리했습니다.
테스트 픽스처(Fixture)는 암호별로 직접 생성
서명된 PDF, 변조된 PDF, CA 체인, CRL, 타임스탬프 토큰과 같은 테스트 소재는 모두 pkijs + WebCrypto를 사용하여 인메모리(in-memory)로 생성합니다. 바이너리 자산을 리포지토리에 두지 않는 방침은 pdf-reader-mcp와 동일하지만, 이번에는 "자기 서명 인증서(Self-signed Certificate) 발행 → CMS 서명 구축 → ByteRange 계산"까지 코드로 재현했습니다. 변조 테스트는 "서명된 PDF의 서명 대상 바이트를 1바이트 수정하는" 함수로 만듭니다.
const identity = await createTestIdentity(); // 인증서+키 쌍 생성
const signed = await createSignedPdf(identity); // 서명된 PDF 구축
const tampered = tamperSignedPdf(signed); // 1바이트 변조
암호화 관련 E2E 테스트에는 qpdf를 사용하며, RC4 / AES-128 / AES-256의 각 암호화 PDF를 CI 상에서 생성하여 회귀 테스트(Regression Test)를 수행합니다.
PDF family 내에서의 위치
이로써 family의 3개 층이 갖춰졌습니다.
pdf-spec-mcp "사양이 무엇을 요구하는가" (정전층/Canonical Layer)
pdf-reader-mcp "내용물에 무엇이 있는가" (실체층/Entity Layer)
pdf-verify-mcp "그것이 진짜인가" (진정성층/Authenticity Layer)
세 가지는 서로 의존하지 않고 각각 단독으로 동작합니다. verify는 reader 없이도 서명 검증이 완결되며, 그 반대도 마찬가지입니다. 협업이 필요한 상황 — "거래처로부터 받은 서명된 PDF의 수입 감사", "전자문서법 대응을 위한 장기 보존 체크"와 같이 여러 도구를 가로지르는 절차 — 는 MCP 서버를 늘리는 것이 아니라 **Skill (절차서)**로서 기술하는 방침을 취하고 있습니다. 이 설계 결정에 관한 이야기는 나중에 별도의 기사로 작성할 예정입니다.
셋업 (Setup)
npx로 즉시 실행할 수 있습니다. Claude Desktop의 claude_desktop_config.json:
{
"mcpServers": {
"pdf-verify": {
...
Claude Code:
claude mcp add pdf-verify -- npx -y @shuji-bonji/pdf-verify-mcp
옵션 설정:
| 환경 변수 | 용도 |
|---|---|
PDF_VERIFY_TRUST_ANCHORS | 신뢰하는 CA 인증서 디렉토리 (_.pem / _.crt / _.cer / _.der) |
PDF_VERIFY_VERAPDF | veraPDF 실행 파일 경로 (PATH 및 Homebrew의 알려진 경로는 자동 감지) |
요약
pdf-verify-mcp는 "PDF의 진정성을 LLM이 검증할 수 있도록 하는" MCP 서버입니다.
- 변조 탐지 (Tamper Detection) — ByteRange 다이제스트 (Digest)의 독립적 대조, 증분 업데이트 (Incremental Update) 분석, DocMDP 인증 위반 탐지
- 서명자 검증 (Signer Verification) — 신뢰 체인 (Trust Chain) 평가, 임베디드/온라인 폐기 확인, AIA를 통한 체인 보완
- 장기 서명 (Long-term Signature) — RFC 3161 타임스탬프 완전 검증, LTV 데이터의 실재성 검증을 포함한 PAdES 레벨 판정
- PDF/A 검증 — veraPDF 위임 + 내장 규칙의 하이브리드 방식. "위반 없음 ≠ 인증"을 정직하게 반환하는 설계
- 실제 PDF 대응 — MD5 레거시 서명, RC4/AES 암호화 PDF의 복호화
그리고 다시 한번 강조하지만, trust_anchors가 없는 "valid"는 암호학적 완전성(Cryptographic Integrity)만을 주장하는 것입니다. 도구의 설명문과 응답 모두에 이 주의 사항을 명시하고 있습니다. LLM에게 검증을 맡기기 때문에, "무엇을 검증했고 무엇을 검증하지 않았는지"를 기계 판독 가능하게(Machine-readable) 반환하는 것이 중요하다고 생각합니다.
링크
- GitHub: https://github.com/shuji-bonji/pdf-verify-mcp
- npm: https://www.npmjs.com/package/@shuji-bonji/pdf-verify-mcp
관련 기사
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기