đź’ˇ Full working example available on GitHub: sign-docx-with-mldsa-certificates-python

Introduction

Sign a contract this afternoon with RSA-2048 and you have made a promise that has to hold for as long as the contract matters. If that is twenty or thirty years - and for deeds, consent forms and engineering sign-off it often is - the promise has to outlast the algorithm. The attack does not need to exist today. It needs to exist before the document stops mattering, and then anyone holding the public key can derive the private one and sign in your name.

Post-quantum document signing is the GroupDocs.Signature feature for Python that replaces that promise with one built on ML-DSA, the signature algorithm NIST standardised as FIPS 204 in 2024. Word-format support arrived in GroupDocs.Signature 26.9, and it reuses the API you already have: an ML-DSA key lives in a PFX and goes into DigitalSignOptions exactly like an RSA key.

This guide signs a DOCX in four steps, compares the three security levels on measured output, verifies the signature with nothing but a public certificate, and ends with the two limits worth knowing before you commit.

Why This Matters More Than the Usual Migration

Signature migration is unlike encryption migration in one respect that makes it easier to postpone and more awkward to fix.

With encryption, the harvest-now-decrypt-later problem is immediate: anything intercepted today can be stored and opened later. With signatures, nothing you have already signed becomes forgeable retroactively - but nothing you signed stays provably yours either, once the key can be derived from the certificate everyone has a copy of. Re-signing a decade of archived documents with new keys is possible and nobody wants to be the person planning it.

That is why the practical advice is narrow rather than sweeping: migrate the documents whose retention is long, leave the rest. Some profiles have already set the bar - CNSA 2.0 requires ML-DSA-87 for national-security systems - and for everyone else the deciding factor is how long the file has to remain defensible.

Prerequisites

  • Python 3.9 or later on a 64-bit interpreter - the package ships a bundled .NET runtime and has no 32-bit wheel
  • GroupDocs.Signature for Python via .NET 26.10.0, with a free temporary licence to remove the evaluation limits
  • An ML-DSA certificate as a password-protected PFX, and a Word document to sign

Installation

pip install groupdocs-signature-net

Step 1 - Sign with an ML-DSA certificate

The certificate does the work. The call is the one you would write for RSA:

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

    result = sign.sign(output_path, options)

That is the entire adoption story for code that already signs: point DigitalSignOptions at a different PFX. No new option, no separate algorithm parameter, no branch for post-quantum.

Reading the signer back out takes one more step, and contains the one Python-specific trap in this whole exercise:

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

The certificate on a DigitalSignature is a bridge object that resolves attributes dynamically. certificate.subject returns CN=GroupDocs.Signature MLDSA65 test, while dir() on that same object lists nothing at all. I inspected it with dir() first, concluded the subject was not exposed, and was simply wrong - so if you introspect before reading, you will skip a value that is there.

Step 2 - Compare the three security levels

ML-DSA comes in three parameter sets, and they are selected by handing over a different certificate:

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)

This is the step worth actually running, because the trade-off is usually described and rarely measured. From a 132 KB source contract:

Level NIST security category Signed file Over the smallest
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

Under 6 KB separates the weakest level from the strongest. On a contract that is nothing, which collapses the decision: use ML-DSA-65 as a default, ML-DSA-87 where a profile demands category 5 or where size is irrelevant, and ML-DSA-44 only when you are signing so many files that kilobytes aggregate into something real.

Step 3 - Verify with a public certificate

A recipient needs the signer’s public certificate and nothing secret:

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

The sample calls this twice on the same file: once with mldsa65.cer, the public half of the signing key, and once with a different signer’s PFX. The first returns True, the second False. Note that the wrong certificate gives a False rather than raising - “signed by someone else” is an answer your code should handle, not an exception. The check covers the document content along with the certificate’s serial number and thumbprint, so a file edited after signing also fails.

Step 4 - Read the signatures out of a document

When a signed document arrives and you do not know which certificate to expect:

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

search with SignatureType.DIGITAL returns DigitalSignature objects carrying the certificate, the signing time and a validity flag. A Word document can hold several signatures, including a mix of RSA and ML-DSA ones, and each is reported with its own certificate and its own validity.

Does this change how recipients verify?

Not in any way they will notice. A recipient still needs only the signer’s public certificate, still passes it to the same DigitalVerifyOptions, and still reads a boolean back. Nothing about the verification path is specific to ML-DSA. The one place the algorithm shows through is Microsoft Word’s own signature indicator, which may not recognise ML-DSA yet because the format has no standard identifier for it.

Real-World Applications

Long-retention contracts

The clearest case. A document that must stay verifiable for decades is signed once, now, with ML-DSA-65 or ML-DSA-87, and never needs re-signing because its algorithm aged out.

Regulated environments with a named profile

Where CNSA 2.0 or a similar profile applies, the level is not a judgement call - ML-DSA-87 is the requirement, and the only engineering question is whether the format is supported.

Mixed pipelines during migration

Signing new documents post-quantum while leaving the archive alone is a perfectly reasonable intermediate state, and search reporting each signature separately is what makes it manageable.

Best Practices and Tips

  • Migrate by retention, not by volume. The documents that need this are the long-lived ones; a receipt that matters for 90 days does not.
  • Default to ML-DSA-65 unless a profile names a level, and do not agonise over the size difference - it is under 6 KB per signature.
  • Keep RSA where the recipient validates in Word. Correct signatures that a reader flags are worse than a slower migration.
  • Replace the test certificates. The sample’s PFX files are self-signed with a published password, so anything signed with them proves nothing.
  • Verify after signing in any pipeline, using the public certificate a recipient would have.

Troubleshooting Common Issues

Microsoft Word does not show the signature as valid. Expected for now: there is no standard XML-DSig identifier for ML-DSA, so Word may not recognise it even though the signature is correct and GroupDocs.Signature verifies it. Verify in your own pipeline, and keep RSA for documents whose recipients rely on Word’s indicator.

The signing call rejects a PDF or spreadsheet. ML-DSA signing covers Word formats - DOCX, DOC, ODT and the rest. PDF, spreadsheets and presentations are not supported yet, and still sign with RSA or ECDSA as before.

The certificate subject comes back empty. Almost always the dir() trap from Step 1: the attribute resolves dynamically, so read it rather than testing for it first.

Conclusion

The code change is a certificate change, which is the part that makes this worth doing before it is urgent. Sign the long-lived Word documents with ML-DSA-65, use ML-DSA-87 where a profile requires it, verify with the public certificate, and keep RSA where the format or the reader demands it.

Run the sample against one of your own contracts and the three sizes will tell you, in bytes, exactly what the strongest available level costs you. On the file I tested it was 5,769 bytes.

Additional Resources