💡 전체 작동 예제가 GitHub에 있습니다:
sign-docx-with-mldsa-certificates-python

소개

오후에 RSA‑2048로 계약서에 서명하면, 그 계약이 의미를 갖는 한 약속이 유지됩니다. 그 기간이 20년이든 30년이든—특히 증서, 동의서, 엔지니어링 승인서의 경우가 그렇습니다—그 약속은 알고리즘보다 오래 지속되어야 합니다. 공격이 오늘 존재할 필요는 없으며, 문서가 더 이상 의미를 갖지 않기 전에 존재하면 됩니다. 그때 공개키를 가진 사람은 개인키를 유도해 당신의 이름으로 서명할 수 있습니다.

포스트‑퀀텀 문서 서명은 Python용 GroupDocs.Signature 기능으로, 약속을 2024년에 NIST가 FIPS 204로 표준화한 서명 알고리즘 ML‑DSA 기반으로 교체합니다. Word 형식 지원은 GroupDocs.Signature 26.9에서 도입되었으며, 기존 API를 그대로 사용합니다: ML‑DSA 키는 PFX에 저장되고 DigitalSignOptions에 RSA 키와 동일하게 전달됩니다.

이 가이드는 DOCX 파일을 네 단계로 서명하고, 세 가지 보안 수준을 측정된 출력으로 비교하며, 공개 인증서만으로 서명을 검증하고, 적용 전에 알아두면 좋은 두 가지 제한 사항을 정리합니다.

왜 일반적인 마이그레이션보다 더 중요한가

서명 마이그레이션은 암호화 마이그레이션과 달리 연기하기 쉬우면서도 수정하기 번거로운 한 가지 측면이 있습니다.

암호화에서는 “지금 수집‑나중에 복호화” 문제가 즉각적으로 나타납니다: 오늘 가로채면 나중에 저장해 두고 열 수 있습니다. 서명에서는 이미 서명한 것이 소급해서 위조될 수는 없지만, 키가 인증서에서 유도될 수 있게 되면 서명 자체가 더 이상 본인 것임을 증명할 수 없게 됩니다. 수십 년 된 보관 문서를 새 키로 다시 서명하는 것은 가능하지만, 이를 계획하는 사람은 거의 없습니다.

따라서 실용적인 조언은 포괄적이라기보다 제한적입니다: 보존 기간이 긴 문서만 마이그레이션하고, 나머지는 그대로 두세요. 일부 프로파일은 이미 기준을 정했습니다—CNSA 2.0은 국가 보안 시스템에 ML‑DSA‑87을 요구합니다—그 외에는 파일이 얼마나 오래 방어 가능해야 하는지가 결정 요인입니다.

전제 조건

  • 64비트 인터프리터에서 Python 3.9 이상
  • .NET 런타임이 번들된 패키지이며 32비트 휠이 없습니다
  • GroupDocs.Signature for Python via .NET 26.10.0, 평가 제한을 해제하는 무료 임시 라이선스
  • 비밀번호로 보호된 PFX 형식의 ML‑DSA 인증서와 서명할 Word 문서

설치

pip install groupdocs-signature-net

1단계 – ML‑DSA 인증서로 서명하기

인증서가 모든 작업을 수행합니다. RSA용으로 작성한 코드를 그대로 사용하면 됩니다:

with signature.Signature(source_path) as sign:
    options = DigitalSignOptions(pfx_path)
    options.password = CERTIFICATE_PASSWORD

    result = sign.sign(output_path, options)

이미 서명하는 코드를 DigitalSignOptions에 다른 PFX를 지정하기만 하면 되는 전체 채택 사례입니다. 새로운 옵션도 없고, 별도의 알고리즘 매개변수도 없으며, 포스트‑퀀텀을 위한 별도 분기도 없습니다.

서명자를 다시 읽어오는 데는 한 단계가 더 필요하며, 전체 과정에서 유일한 Python‑특화 함정이 포함됩니다:

for created in result.succeeded:
    certificate = getattr(created, "certificate", None)
    subject = getattr(certificate, "subject", None)
    if subject:
        return str(subject)

DigitalSignature의 인증서는 동적으로 속성을 해결하는 브리지 객체입니다. certificate.subject는 CN=GroupDocs.Signature MLDSA65 test를 반환하지만, 같은 객체에 dir()을 호출하면 아무 것도 표시되지 않습니다. 처음에 dir()로 조사했을 때 속성이 노출되지 않는다고 판단해 잘못된 결론을 내렸던 것이죠—읽기 전에 introspect하면 존재하는 값을 놓치게 됩니다.

2단계 – 세 가지 보안 수준 비교하기

ML‑DSA는 세 가지 파라미터 세트를 제공하며, 서로 다른 인증서를 전달함으로써 선택합니다:

levels = (
    ("ML-DSA-44", MLDSA44_PFX),
    ("ML-DSA-65", MLDSA65_PFX),
    ("ML-DSA-87", MLDSA87_PFX),
)

for level, pfx_path in levels:
    with signature.Signature(source_path) as sign:
        options = DigitalSignOptions(pfx_path)
        options.password = CERTIFICATE_PASSWORD
        sign.sign(output_path, options)

    sizes[f"{level} -> {file_name}"] = os.path.getsize(output_path)

실제로 실행해 볼 가치가 있는 단계입니다. 보통은 설명만 되고 실제 측정은 드물기 때문이죠. 132 KB 원본 계약서 기준:

레벨 NIST 보안 카테고리 서명된 파일 가장 작은 파일 대비
ML-DSA-44 2 138,202 bytes -
ML-DSA-65 3 140,650 bytes +2,448 bytes
ML-DSA-87 5 143,971 bytes +5,769 bytes

가장 약한 수준과 가장 강한 수준 사이의 차이는 6 KB 미만입니다. 계약서 정도라면 이는 무시할 수 있는 차이이며, 결정은 다음과 같이 단순해집니다: 기본값은 ML‑DSA‑65, 프로파일이 카테고리 5를 요구하거나 파일 크기가 중요하지 않을 때는 ML‑DSA‑87, 그리고 파일 수가 많아 킬로바이트가 실질적인 비용이 될 때만 ML‑DSA‑44를 사용합니다.

3단계 – 공개 인증서로 검증하기

수신자는 서명자의 공개 인증서와 비밀이 전혀 없는 상태만 필요합니다:

with signature.Signature(signed_path) as sign:
    options = DigitalVerifyOptions(certificate_path)
    if password is not None:
        options.password = password

    return sign.verify(options).is_valid

샘플은 같은 파일에 두 번 호출합니다: 첫 번째는 서명 키의 공개 절반인 mldsa65.cer를 사용하고, 두 번째는 다른 서명자의 PFX를 사용합니다. 첫 번째는 True, 두 번째는 False를 반환합니다. 잘못된 인증서는 예외를 발생시키지 않고 False를 반환한다는 점에 유의하세요—“다른 사람에 의해 서명됨”은 코드에서 처리해야 할 결과이며 예외가 아닙니다. 검증은 문서 내용과 인증서의 일련 번호 및 지문을 모두 확인하므로 서명 후 파일이 수정되면 검증에 실패합니다.

4단계 – 문서에서 서명 읽어오기

서명된 문서를 받았는데 어떤 인증서를 기대해야 할지 모를 때:

with signature.Signature(signed_path) as sign:
    found = sign.search(SignatureType.DIGITAL)
    for item in found:
        print(item.sign_time, item.is_valid)

SignatureType.DIGITAL로 search하면 인증서, 서명 시간, 유효성 플래그를 포함한 DigitalSignature 객체들을 반환합니다. Word 문서는 여러 서명을 가질 수 있으며, RSA와 ML‑DSA 서명이 혼합될 수도 있고, 각각은 자체 인증서와 유효성으로 보고됩니다.

수신자가 검증 방법을 바꿔야 할까?

전혀 그렇지 않습니다. 수신자는 여전히 서명자의 공개 인증서만 필요하고, 동일한 DigitalVerifyOptions에 전달한 뒤 불리언 값을 반환받습니다. 검증 경로에 ML‑DSA에 특화된 부분은 없습니다. 알고리즘이 드러나는 유일한 지점은 Microsoft Word 자체 서명 표시기로, 아직 표준 식별자가 없기 때문에 ML‑DSA를 인식하지 못할 수 있습니다.

실제 적용 사례

장기 보존 계약

가장 명확한 경우입니다. 수십 년 동안 검증 가능해야 하는 문서는 지금 ML‑DSA‑65 또는 ML‑DSA‑87로 한 번 서명하면 알고리즘이 오래돼도 재서명이 필요 없습니다.

명시된 프로파일이 있는 규제 환경

CNSA 2.0 등과 같이 프로파일이 적용되는 경우, 수준은 판단이 아니라 요구 사항이며—ML‑DSA‑87이 필요하고—엔지니어링 질문은 포맷 지원 여부뿐입니다.

마이그레이션 중 혼합 파이프라인

새 문서는 포스트‑퀀텀으로 서명하고 기존 아카이브는 그대로 두는 것은 합리적인 중간 상태이며, search가 각 서명을 별도로 보고해 관리가 용이합니다.

모범 사례 및 팁

  • 보존 기간 기준으로 마이그레이션하고, 양이 아니라 기간에 따라 판단하세요. 90일 정도 유효한 영수증은 대상이 아닙니다.
  • 프로파일이 지정하지 않은 경우 기본은 ML‑DSA‑65이며, 서명당 6 KB 미만 차이이므로 크기 차이에 집착하지 마세요.
  • 수신자가 Word에서 검증한다면 RSA 유지가 좋습니다. 독자가 플래그를 다는 올바른 서명은 느린 마이그레이션보다 낫습니다.
  • 테스트 인증서를 교체하세요. 샘플의 PFX 파일은 공개 비밀번호가 있는 자체 서명 인증서이므로, 이를 사용해 서명해도 아무 의미가 없습니다.
  • 서명 후 검증을 파이프라인 어디에서든 수행하고, 수신자가 가질 공개 인증서를 사용하세요.

일반적인 문제 해결

Microsoft Word가 서명을 유효하다고 표시하지 않음. 현재는 정상입니다: ML‑DSA에 대한 표준 XML‑DSig 식별자가 없으므로 Word가 인식하지 못할 수 있습니다. 자체 파이프라인에서 검증하고, Word 표시기에 의존하는 문서는 RSA를 유지하세요.

서명 호출이 PDF나 스프레드시트를 거부함. ML‑DSA 서명은 Word 형식—DOCX, DOC, ODT 등—만 지원합니다. PDF, 스프레드시트, 프레젠테이션은 아직 지원되지 않으며 기존처럼 RSA 또는 ECDSA를 사용해야 합니다.

인증서 주제가 비어 있음. 거의 항상 1단계의 dir() 함정 때문입니다. 속성이 동적으로 해결되므로 먼저 테스트하지 말고 바로 읽어야 값을 얻을 수 있습니다.

결론

코드 변경은 인증서 교체에 불과합니다. 이는 긴급 상황이 되기 전에 실행할 가치가 있는 부분입니다. 장기 보존 Word 문서는 ML‑DSA‑65로 서명하고, 프로파일이 요구하면 ML‑DSA‑87을 사용하며, 공개 인증서로 검증하고, 포맷이나 리더가 요구하는 경우에만 RSA를 유지하세요.

샘플을 실제 계약서에 적용해 보면 세 가지 크기가 바이트 단위로 정확히 얼마나 비용이 드는지 알 수 있습니다. 제가 테스트한 파일에서는 5,769 bytes 차이가 있었습니다.

추가 자료