Skip to content

seal: a CA mid-rotation is reported as not having signed the seal #334

Description

@LKSNDRTMLKV

cades::check_path_to walks the seal's embedded certificate chain and, at each link, takes the first embedded certificate whose subject matches the current certificate's issuer:

let Some(next) = signed.chain.iter().find(|c| {
    c.tbs_certificate.subject == current.tbs_certificate.issuer
        && c.tbs_certificate.subject != current.tbs_certificate.subject
}) else {
    return Ok(IssuerCheck::NotSignedByThisIssuer);
};match verified_under(next, &key, current) {
    IssuerCheck::Verified => {}
    other => return Ok(other),
}

If that certificate is not the one that signed this link, the walk returns immediately. No other certificate with the same subject is tried.

Why a seal carries two certificates for one subject

Because certificate authorities rotate keys, and publish the old and the new certificate together under one subject name so relying parties do not break at the cutover. A CAdES seal made during that window can legitimately carry both.

This is already established in this codebase. qualification::find_issuer_candidates collects every listed certificate under a matching subject for exactly this reason, and its doc comment says why:

A certificate authority rotating its key publishes the old and the new certificate together, under the same subject name, so relying parties do not break at the cutover — the trusted list module already records that Finland names five signing certificates for this reason. Stopping at the first match would verify against whichever the list happened to order first and report a genuine seal as unsigned, intermittently and only for providers mid-rotation.

The same reasoning applies to the embedded chain, and the embedded chain does not follow it.

What it produces

check_path_to returns NotSignedByThisIssuer. With no candidate verifying, qualification::standing reaches:

None => IssuerStanding::SignatureNotFromListedCa { issuer, provider, territory },

That is the variant the module reserves for a specific meaning, and documents as such:

Never collapsed into the variant below. "We could not check" and "this CA did not sign it" are opposite findings, and only one of them is an accusation.

So a genuine qualified seal is reported as one whose certificate was not signed by the CA the trusted list names — the accusatory reading — because the walk picked the wrong one of two certificates the CA published on purpose. Intermittent, and only during a rotation, which is also when it is hardest to diagnose.

Severity

Not a bypass. It fails in the conservative direction for trust, and the wrong direction for truth: the verdict is an accusation against a provider who did nothing wrong, on a node an operator or an authority is reading to decide whether evidence stands up.

Fix

Mirror find_issuer_candidates: gather every embedded certificate whose subject matches the current issuer, and accept the link if any of them verifies. Reserve NotSignedByThisIssuer for the case where none does.

Worth deciding at the same time whether Unverifiable from one same-subject candidate should suppress a NotSignedByThisIssuer from another — by the module's own distinction it should, since "we could not check one of them" is not "none of them signed it".

Where it came from

Found reviewing #322, which is where check_path_to is introduced. Filed rather than fixed in that PR because the PR is already 73 files and the fix wants its own test — a two-certificate rotation fixture that fails on the current walk and passes on the fixed one.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    surface/sealMoves sealing, trusted lists or trust-service procurementtype/defectSomething published or encoded here is wrong or unbackable nowurgency/nextBlocks work already scheduled

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions