TimestampRule.java

/*
 * Copyright 2026 Emmanuel Bourg
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package net.jsign.verify;

import java.io.IOException;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.cert.CertStore;
import java.security.cert.CertificateParsingException;
import java.security.cert.CollectionCertStoreParameters;
import java.security.cert.X509Certificate;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.List;

import org.bouncycastle.asn1.ASN1Encodable;
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import org.bouncycastle.asn1.DEROctetString;
import org.bouncycastle.asn1.DERSet;
import org.bouncycastle.asn1.cms.Attribute;
import org.bouncycastle.asn1.cms.AttributeTable;
import org.bouncycastle.asn1.cms.CMSAttributes;
import org.bouncycastle.asn1.cms.CMSObjectIdentifiers;
import org.bouncycastle.asn1.cms.ContentInfo;
import org.bouncycastle.asn1.cms.SignedData;
import org.bouncycastle.asn1.cms.SignerInfo;
import org.bouncycastle.asn1.cms.Time;
import org.bouncycastle.asn1.x509.KeyPurposeId;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.jcajce.JcaCertStoreBuilder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cms.CMSProcessableByteArray;
import org.bouncycastle.cms.CMSSignedData;
import org.bouncycastle.cms.DefaultCMSSignatureAlgorithmNameGenerator;
import org.bouncycastle.cms.SignerInformation;
import org.bouncycastle.cms.SignerInformationVerifier;
import org.bouncycastle.cms.jcajce.JcaSimpleSignerInfoVerifierBuilder;
import org.bouncycastle.operator.DefaultSignatureAlgorithmIdentifierFinder;
import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder;
import org.bouncycastle.tsp.TSPException;
import org.bouncycastle.util.Store;
import org.bouncycastle.util.encoders.Hex;

import net.jsign.CertificateChain;
import net.jsign.CertificateVerifier;
import net.jsign.PublicKeyHash;

import static net.jsign.asn1.authenticode.AuthenticodeObjectIdentifiers.*;
import static net.jsign.verify.CheckResult.Status.*;
import static org.bouncycastle.asn1.cms.CMSAttributes.*;
import static org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers.*;

/**
 * Verification rules checking if the signature contains a valid timestamp.
 *
 * <p>This rule is skipped if the signing certificate has the lifetime signing EKU.</p>
 *
 * <p>A signature may have multiple timestamps, but Windows takes only one into
 * account:</p>
 * <ul>
 *   <li>An Authenticode timestamp has the priority over a RFC 3161 timestamp.</li>
 *   <li>If the signature contains multiple timestamps of the same type, Windows
 *       picks one with an unknown criteria. It's neither the first nor the last one,
 *       and some timestamping certificates seem to take precedence over others.</li>
 * </ul>
 *
 * <p>This verification rule prioritizes the Authenticode timestamp over the
 * RFC 3161 timestamp, and takes the first of a given type if multiple timestamps
 * are present.</p>
 *
 * @since 8.0
 */
class TimestampRule extends VerificationRule {

    @Override
    public CheckResult check(VerificationContext context) throws IOException {
        // Ignore the timestamp if the certificate has the lifetime signing key purpose
        try {
            List<String> extendedKeyUsage = context.getSignerCertificate().getExtendedKeyUsage();
            if (extendedKeyUsage != null && extendedKeyUsage.contains(OID_KP_LIFETIME_SIGNING.getId())) {
                return new CheckResult(getName(), SKIPPED, "Certificate has the lifetime signing key purpose, timestamp is ignored");
            }
        } catch (CertificateParsingException e) {
            throw new IOException(e);
        }

        SignerInformation signer = context.getSignerInformation();
        AttributeTable unsignedAttributes = signer.getUnsignedAttributes();

        if (unsignedAttributes != null) {
            // The Authenticode timestamp takes precedence over the RFC 3161 timestamp
            Attribute timestampAttribute = unsignedAttributes.get(counterSignature);
            if (timestampAttribute != null) {
                return verifyAuthenticodeTimestamp(timestampAttribute, context);
            }

            timestampAttribute = unsignedAttributes.get(id_aa_signatureTimeStampToken);
            if (timestampAttribute == null) {
                timestampAttribute = unsignedAttributes.get(SPC_RFC3161_OBJID);
            }
            if (timestampAttribute != null) {
                return verifyRFC3161Timestamp(timestampAttribute, context);
            }
        }

        return new CheckResult(getName(), SKIPPED, "No timestamp found");
    }

    private CheckResult verifyRFC3161Timestamp(Attribute timestampAttribute, VerificationContext context) {
        try {
            CMSSignedData tokenSignedData = new CMSSignedData(ContentInfo.getInstance(timestampAttribute.getAttrValues().getObjectAt(0)));
            AuthenticodeTimeStampToken timestampToken = new AuthenticodeTimeStampToken(tokenSignedData);
            AuthenticodeTimeStampTokenInfo info = timestampToken.getTimeStampInfo();

            ASN1ObjectIdentifier hashAlgOid = info.getHashAlgorithm().getAlgorithm();
            MessageDigest digestEngine = MessageDigest.getInstance(hashAlgOid.getId());
            byte[] expectedDigest = digestEngine.digest(context.getSignerInformation().getSignature());
            byte[] actualDigest = info.getMessageImprintDigest();

            if (!Arrays.equals(expectedDigest, actualDigest)) {
                String expectedHex = Hex.toHexString(expectedDigest);
                String actualHex = Hex.toHexString(actualDigest);
                return new CheckResult(getName(), FAILED, "RFC 3161 timestamp message imprint mismatch", expectedHex, actualHex);
            }

            Collection<X509CertificateHolder> matches = timestampToken.getCertificates().getMatches(timestampToken.getSID());

            if (matches.isEmpty()) {
                return new CheckResult(getName(), FAILED, "RFC 3161 timestamping authority certificate not found in the timestamp token");
            }

            X509CertificateHolder timestampCertificate = matches.iterator().next();

            SignerInformationVerifier verifier = new JcaSimpleSignerInfoVerifierBuilder().build(timestampCertificate);
            try {
                timestampToken.validate(verifier);
            } catch (TSPException e) {
                return new CheckResult(getName(), FAILED, "RFC 3161 timestamp cryptographic signature is invalid", e);
            }

            try {
                verifyTimestampCertificate(timestampCertificate, timestampToken.getCertificates(), info.getGenTime());
            } catch (GeneralSecurityException e) {
                return new CheckResult(getName(), FAILED, "RFC 3161 timestamp certificate is not trusted", e);
            }

            Date timestampDate = info.getGenTime();
            context.setDate(timestampDate);

            return new CheckResult(getName(), PASSED, "Valid RFC 3161 timestamp (" + timestampDate + ")");

        } catch (Exception e) {
            return new CheckResult(getName(), FAILED, "Invalid RFC 3161 timestamp signature or structure", e);
        }
    }

    private CheckResult verifyAuthenticodeTimestamp(Attribute timestampAttribute, VerificationContext context) {
        try {
            ASN1Encodable attrValue = timestampAttribute.getAttrValues().getObjectAt(0);
            SignerInfo signerInfo = SignerInfo.getInstance(attrValue.toASN1Primitive());
            SignerInformation tsaSigner = createStandaloneSignerInformation(signerInfo, context.getSignerInformation().getSignature());

            Attribute signingTimeAttr = tsaSigner.getSignedAttributes().get(CMSAttributes.signingTime);
            if (signingTimeAttr == null) {
                return new CheckResult(getName(), FAILED, "Authenticode timestamp missing signingTime attribute");
            }

            Time time = Time.getInstance(signingTimeAttr.getAttrValues().getObjectAt(0));
            Date timestampDate = time.getDate();

            Collection<?> matchCerts = context.getSignature().getCertificates().getMatches(tsaSigner.getSID());
            if (matchCerts.isEmpty()) {
                return new CheckResult(getName(), FAILED, "Authenticode timestamping authority certificate not found");
            }

            X509CertificateHolder timestampCertificate = (X509CertificateHolder) matchCerts.iterator().next();

            SignerInformationVerifier verifier = new SignerInformationVerifier(
                    new DefaultCMSSignatureAlgorithmNameGenerator(),
                    new DefaultSignatureAlgorithmIdentifierFinder(),
                    new AuthenticodeContentVerifierProviderBuilder().build(timestampCertificate),
                    new JcaDigestCalculatorProviderBuilder().build());

            if (!tsaSigner.verify(verifier)) {
                return new CheckResult(getName(), FAILED, "Authenticode timestamp cryptographic signature is invalid");
            }

            try {
                verifyTimestampCertificate(timestampCertificate, context.getSignature().getCertificates(), timestampDate);
            } catch (GeneralSecurityException e) {
                return new CheckResult(getName(), FAILED, "Authenticode timestamp certificate is not trusted", e);
            }

            context.setDate(timestampDate);

            return new CheckResult(getName(), PASSED, "Valid Authenticode timestamp (" + timestampDate + ")");

        } catch (Exception e) {
            return new CheckResult(getName(), FAILED, "Invalid Authenticode timestamp structure or signature", e);
        }
    }

    private void verifyTimestampCertificate(X509CertificateHolder certificate, Store<X509CertificateHolder> store, Date date) throws IOException, GeneralSecurityException {
        X509Certificate cert = new JcaX509CertificateConverter().getCertificate(certificate);
        CertificateChain chain = CertificateChain.build(cert, new JcaCertStoreBuilder().addCertificates(store).build()).complete();
        CertStore completedStore = CertStore.getInstance("Collection", new CollectionCertStoreParameters(chain.toList()));

        CertificateVerifier verifier = new CertificateVerifier()
                .withTrustedKeys(PublicKeyHash.load(getClass().getClassLoader().getResourceAsStream("windows-trusted-keys.csv")))
                .withCertificateStore(completedStore)
                .withKeyPurposeId(KeyPurposeId.id_kp_timeStamping)
                .withDate(date);
        verifier.verify(cert);
    }

    /**
     * Transforms the SignerInfo from the counter-signature into a SignerInformation without
     * the counter-signature flag. This is necessary because the authenticode counter-signature
     * contains a signed content-type attribute that prevents Bouncy Castle from verifying it
     * (this attribute is not allowed for counter-signatures according to RFC 3852).
     */
    private SignerInformation createStandaloneSignerInformation(SignerInfo signerInfo, byte[] encryptedDigest) throws Exception {
        ASN1ObjectIdentifier contentType = CMSObjectIdentifiers.data;
        AttributeTable attributes = new AttributeTable(signerInfo.getAuthenticatedAttributes());
        Attribute contentTypeAttribute = attributes.get(CMSAttributes.contentType);
        if (contentTypeAttribute != null && contentTypeAttribute.getAttrValues().size() > 0) {
            contentType = ASN1ObjectIdentifier.getInstance(contentTypeAttribute.getAttrValues().getObjectAt(0));
        }

        ContentInfo encapContentInfo = new ContentInfo(contentType, new DEROctetString(encryptedDigest));
        SignedData signedData = new SignedData(new DERSet(signerInfo.getDigestAlgorithm()), encapContentInfo, null, null, new DERSet(signerInfo));

        ContentInfo contentInfo = new ContentInfo(CMSObjectIdentifiers.signedData, signedData);
        CMSSignedData cmsSignedData = new CMSSignedData(new CMSProcessableByteArray(contentType, encryptedDigest), contentInfo);

        return cmsSignedData.getSignerInfos().getSigners().iterator().next();
    }
}