SignatureVerifierTest.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.File;
import java.io.FileInputStream;
import java.nio.ByteBuffer;
import java.nio.channels.SeekableByteChannel;
import java.nio.file.Files;
import java.nio.file.StandardOpenOption;
import java.security.KeyStore;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.Date;
import org.apache.commons.io.FileUtils;
import org.bouncycastle.asn1.ASN1Sequence;
import org.bouncycastle.asn1.DEROctetString;
import org.bouncycastle.asn1.cms.ContentInfo;
import org.bouncycastle.asn1.cms.SignedData;
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
import org.bouncycastle.asn1.x509.DigestInfo;
import org.bouncycastle.cms.CMSSignedData;
import org.bouncycastle.cms.CMSTypedData;
import org.junit.Ignore;
import org.junit.Test;
import net.jsign.AuthenticodeSigner;
import net.jsign.DigestAlgorithm;
import net.jsign.KeyStoreBuilder;
import net.jsign.MockSignable;
import net.jsign.Signable;
import net.jsign.asn1.authenticode.SpcIndirectDataContent;
import net.jsign.timestamp.TimestampingMode;
import static net.jsign.DigestAlgorithm.*;
import static net.jsign.verify.CheckResult.Status.*;
import static org.junit.Assert.*;
public class SignatureVerifierTest {
private static final String PRIVATE_KEY_PASSWORD = "password";
private static final String ALIAS = "test";
private KeyStore getKeyStore() throws Exception {
return new KeyStoreBuilder().keystore("target/test-classes/keystores/keystore.jks").storepass("password").build();
}
private X509Certificate loadCertificate(String filename) throws Exception {
try (FileInputStream in = new FileInputStream(filename)) {
return (X509Certificate) CertificateFactory.getInstance("X.509").generateCertificate(in);
}
}
@Test
public void testVerifyUnsigned() throws Exception {
File srcFile = new File("target/test-classes/wineyes.exe");
try (Signable signable = Signable.of(srcFile)) {
VerificationResult result = new SignatureVerifier().verify(signable);
assertFalse("invalid signature", result.isValid());
}
}
@Test
public void testVerify() throws Exception {
testSignAndVerify(new File("target/test-classes/wineyes.exe"));
}
@Test
public void testVerifyCatalog() throws Exception {
testSignAndVerify(new File("target/test-classes/cat/wineyes.cat"));
}
@Test
public void testVerifyCabinet() throws Exception {
testSignAndVerify(new File("target/test-classes/mscab/sample1.cab"));
}
@Test
public void testVerifyMSI() throws Exception {
testSignAndVerify(new File("target/test-classes/minimal.msi"));
}
@Test
public void testVerifyAPPX() throws Exception {
testSignAndVerify(new File("target/test-classes/minimal.appxbundle"));
}
@Test
public void testVerifyMSIX() throws Exception {
testSignAndVerify(new File("target/test-classes/minimal.msix"));
}
@Test
public void testVerifyNAVX() throws Exception {
testSignAndVerify(new File("target/test-classes/minimal.navx"));
}
@Test
public void testVerifyVBS() throws Exception {
testSignAndVerify(new File("target/test-classes/hello-world.vbs"));
}
@Test
public void testVerifyJS() throws Exception {
testSignAndVerify(new File("target/test-classes/hello-world.js"));
}
@Test
public void testVerifyWSF() throws Exception {
testSignAndVerify(new File("target/test-classes/hello-world.wsf"));
}
@Test
public void testVerifyPowerShell() throws Exception {
testSignAndVerify(new File("target/test-classes/hello-world.ps1"));
}
@Test
public void testVerifyPowerShellXML() throws Exception {
testSignAndVerify(new File("target/test-classes/hello-world.ps1xml"));
}
@Test
public void testVerifyNuget() throws Exception {
testSignAndVerify(new File("target/test-classes/nuget/minimal.1.0.0.nupkg"));
}
private void testSignAndVerify(File srcFile) throws Exception {
File destFile = new File(srcFile.getParentFile(), srcFile.getName().replaceFirst("(\\.[^.]+)$", "-verified$1"));
FileUtils.copyFile(srcFile, destFile);
AuthenticodeSigner signer = new AuthenticodeSigner(getKeyStore(), ALIAS, PRIVATE_KEY_PASSWORD).withTimestamping(false);
try (Signable signable = Signable.of(destFile)) {
signer.sign(signable);
testVerify(signable, loadCertificate("target/test-classes/keystores/jsign-root-ca.pem"));
}
}
private void testVerify(Signable signable, X509Certificate... trustedCertificates) throws Exception {
SignatureVerifier verifier = new SignatureVerifier();
for (X509Certificate trustedCertificate : trustedCertificates) {
verifier.addTrustedCertificate(trustedCertificate);
}
VerificationResult result = verifier.verify(signable);
if (!result.isValid()) {
for (VerificationResult.SignatureVerification verification : result.getSignatureVerifications()) {
System.out.println("Verification of signature: " + verification.getDigestAlgorithm() + " / " + verification.getCertificate().getSubjectX500Principal());
for (CheckResult check : verification.getChecks()) {
System.out.println(check);
if (check.getError() != null) {
check.getError().printStackTrace();
}
}
System.out.println();
}
}
assertTrue("invalid signature", result.isValid());
}
@Test
public void testVerifyUntrustedCertificate() throws Exception {
File srcFile = new File("target/test-classes/wineyes.exe");
File destFile = new File("target/test-classes/wineyes-verified.exe");
FileUtils.copyFile(srcFile, destFile);
AuthenticodeSigner signer = new AuthenticodeSigner(getKeyStore(), ALIAS, PRIVATE_KEY_PASSWORD).withTimestamping(false);
try (Signable signable = Signable.of(destFile)) {
signer.sign(signable);
SignatureVerifier verifier = new SignatureVerifier();
verifier.addTrustedCertificate(loadCertificate("target/test-classes/keystores/jsign-test-certificate-self-signed.pem"));
VerificationResult result = verifier.verify(signable);
assertFalse("Verification should have failed for an untrusted certificate", result.isValid());
CheckResult checkResult = result.getSignatureVerifications().get(0).getIssues().get(0);
assertEquals("verification rule", "Certificate Chain Trust", checkResult.getRule());
assertEquals("verification message", "Certificate chain is not trusted", checkResult.getMessage());
assertNotNull("verification error is null", checkResult.getError());
assertEquals("verification error", "Unable to find certificate chain.", checkResult.getError().getMessage());
}
}
@Test
public void testVerifyExpiredSigningCertificate() throws Exception {
File srcFile = new File("target/test-classes/wineyes.exe");
File destFile = new File("target/test-classes/wineyes-verified.exe");
FileUtils.copyFile(srcFile, destFile);
AuthenticodeSigner signer = new AuthenticodeSigner(getKeyStore(), ALIAS, PRIVATE_KEY_PASSWORD).withTimestamping(false);
try (Signable signable = Signable.of(destFile)) {
signer.sign(signable);
SignatureVerifier verifier = new SignatureVerifier();
verifier.addTrustedCertificate(loadCertificate("target/test-classes/keystores/jsign-root-ca.pem"));
verifier.setDate(Date.from(LocalDate.of(2077, 5, 5).atStartOfDay().toInstant(ZoneOffset.UTC)));
VerificationResult result = verifier.verify(signable);
assertFalse("Verification should have failed for an expired certificate", result.isValid());
CheckResult checkResult = result.getSignatureVerifications().get(0).getIssues().get(0);
assertEquals("verification rule", "Certificate Chain Trust", checkResult.getRule());
assertEquals("verification message", "Signer certificate is no longer valid on 2077-05-05 (expired on: 2044-05-10)", checkResult.getMessage());
assertNull("verification error is not null", checkResult.getError());
}
}
@Test
public void testVerifyNotYetValidSigningCertificate() throws Exception {
File srcFile = new File("target/test-classes/wineyes.exe");
File destFile = new File("target/test-classes/wineyes-verified.exe");
FileUtils.copyFile(srcFile, destFile);
AuthenticodeSigner signer = new AuthenticodeSigner(getKeyStore(), ALIAS, PRIVATE_KEY_PASSWORD).withTimestamping(false);
try (Signable signable = Signable.of(destFile)) {
signer.sign(signable);
SignatureVerifier verifier = new SignatureVerifier();
verifier.addTrustedCertificate(loadCertificate("target/test-classes/keystores/jsign-root-ca.pem"));
verifier.setDate(Date.from(LocalDate.of(1977, 5, 5).atStartOfDay().toInstant(ZoneOffset.UTC)));
VerificationResult result = verifier.verify(signable);
assertFalse("Verification should have failed for a not yet valid certificate", result.isValid());
CheckResult checkResult = result.getSignatureVerifications().get(0).getIssues().get(0);
assertEquals("verification rule", "Certificate Chain Trust", checkResult.getRule());
assertEquals("verification message", "Signer certificate is not yet valid on 1977-05-05 (valid from: 2024-05-10)", checkResult.getMessage());
assertNull("verification error is not null", checkResult.getError());
}
}
@Test
public void testVerifyAlteredFile() throws Exception {
File srcFile = new File("target/test-classes/wineyes.exe");
File destFile = new File("target/test-classes/wineyes-verified-with-altered-file.exe");
FileUtils.copyFile(srcFile, destFile);
AuthenticodeSigner signer = new AuthenticodeSigner(getKeyStore(), ALIAS, PRIVATE_KEY_PASSWORD).withTimestamping(false);
try (Signable signable = Signable.of(destFile)) {
signer.sign(signable);
}
try (SeekableByteChannel channel = Files.newByteChannel(destFile.toPath(), StandardOpenOption.READ, StandardOpenOption.WRITE)) {
channel.position(1024);
channel.write(ByteBuffer.wrap("Jsign".getBytes()));
}
try (Signable signable = Signable.of(destFile)) {
VerificationResult result = new SignatureVerifier().verify(signable);
assertFalse("Verification should have failed for an altered file", result.isValid());
CheckResult checkResult = result.getSignatureVerifications().get(0).getIssues().get(0);
assertEquals("verification rule", "File Integrity", checkResult.getRule());
assertEquals("verification message", "The file digest does not match the signature", checkResult.getMessage());
assertEquals("verification expected", "7bb369df020cea757619e1c1d678dbca06b638f2cc45b740b5eacfc21e76b160", checkResult.getExpected());
assertEquals("verification actual", "b71699e037a74d9d477787f58c476e34739d09503249d9f43396d64ac8044a81", checkResult.getActual());
}
}
@Test
public void testVerifyCorruptedSignedContent() throws Exception {
File srcFile = new File("target/test-classes/wineyes.exe");
File destFile = new File("target/test-classes/wineyes-verified-with-corrupted-signed-content.exe");
FileUtils.copyFile(srcFile, destFile);
AuthenticodeSigner signer = new AuthenticodeSigner(getKeyStore(), ALIAS, PRIVATE_KEY_PASSWORD).withTimestamping(false);
try (Signable signable = Signable.of(destFile)) {
signer.sign(signable);
CMSSignedData signature = signable.getSignatures().get(0);
signature = modifyContent(signature, "Jsign".getBytes());
signable.setSignature(signature);
VerificationResult result = new SignatureVerifier().verify(signable);
assertFalse("Verification should have failed for a corrupted signed content", result.isValid());
}
}
@Test
public void testVerifyForgedSignedContent() throws Exception {
File srcFile = new File("target/test-classes/wineyes.exe");
File destFile = new File("target/test-classes/wineyes-verified-with-forged-signed-content.exe");
FileUtils.copyFile(srcFile, destFile);
AuthenticodeSigner signer = new AuthenticodeSigner(getKeyStore(), ALIAS, PRIVATE_KEY_PASSWORD).withTimestamping(false);
try (Signable signable = Signable.of(destFile)) {
signer.sign(signable);
CMSTypedData signedContent = signable.getSignatures().get(0).getSignedContent();
SpcIndirectDataContent original = SpcIndirectDataContent.parse(ASN1Sequence.getInstance(signedContent.getContent()));
DigestInfo forgedDigest = new DigestInfo(new AlgorithmIdentifier(SHA1.oid), signable.computeDigest(SHA1));
SpcIndirectDataContent spcIndirectDataContent = new SpcIndirectDataContent(original.getData(), forgedDigest);
CMSSignedData signature = signable.getSignatures().get(0);
signature = modifyContent(signature, spcIndirectDataContent.getEncoded());
signable.setSignature(signature);
signable.save();
}
try (Signable signable = Signable.of(destFile)) {
SignatureVerifier verifier = new SignatureVerifier();
verifier.addTrustedCertificate(loadCertificate("target/test-classes/keystores/jsign-root-ca.pem"));
VerificationResult result = verifier.verify(signable);
assertFalse("Verification should have failed for a forged signed content", result.isValid());
assertEquals("number of issues", 1, result.getSignatureVerifications().get(0).getIssues().size());
CheckResult checkResult = result.getSignatureVerifications().get(0).getIssues().get(0);
assertEquals("verification ruley", "Signature Integrity", checkResult.getRule());
assertEquals("verification message", "Signature data is invalid", checkResult.getMessage());
assertNotNull("missing verification error", checkResult.getError());
assertEquals("verification error", "message-digest attribute value does not match calculated value", checkResult.getError().getMessage());
}
}
/**
* Modifies the signed content of a signature.
*/
public CMSSignedData modifyContent(CMSSignedData signature, byte[] modifiedContent) throws Exception {
SignedData signedData = SignedData.getInstance(signature.toASN1Structure().getContent());
SignedData modifiedSignedData = new SignedData(
signedData.getDigestAlgorithms(),
new ContentInfo(signedData.getEncapContentInfo().getContentType(), new DEROctetString(modifiedContent)),
signedData.getCertificates(),
signedData.getCRLs(),
signedData.getSignerInfos());
return new CMSSignedData(new ContentInfo(signature.toASN1Structure().getContentType(), modifiedSignedData));
}
@Test
public void testVerifyRFC3161Timestamp() throws Exception {
testVerifyTimestamp(TimestampingMode.RFC3161);
}
@Test
public void testVerifyAuthenticodeTimestamp() throws Exception {
testVerifyTimestamp(TimestampingMode.AUTHENTICODE);
}
public void testVerifyTimestamp(TimestampingMode mode) throws Exception {
File srcFile = new File("target/test-classes/wineyes.exe");
File destFile = new File("target/test-classes/wineyes-verified-with-" + mode.name().toLowerCase() + "-timestamp.exe");
FileUtils.copyFile(srcFile, destFile);
AuthenticodeSigner signer = new AuthenticodeSigner(getKeyStore(), ALIAS, PRIVATE_KEY_PASSWORD)
.withTimestamping(true)
.withTimestampingMode(mode);
try (Signable signable = Signable.of(destFile)) {
signer.sign(signable);
SignatureVerifier verifier = new SignatureVerifier();
verifier.addTrustedCertificate(loadCertificate("target/test-classes/keystores/jsign-root-ca.pem"));
VerificationResult result = verifier.verify(signable);
assertTrue("invalid signature", result.isValid());
CheckResult checkResult = result.getSignatureVerifications().get(0).getChecks().get(1);
assertEquals("verification rule", "Timestamp", checkResult.getRule());
assertEquals("verification status", PASSED, checkResult.getStatus());
assertNull("verification error", checkResult.getError());
}
}
@Test
public void testVerifyExpiredTimestampCertificate() throws Exception {
// The timestamp certificate is expired, but the signature is still valid
testVerifyDetachedSignature("expired-timestamp-certificate.p7s", SHA256, "a3a48cae33a8f803e75e8789e4d0aa656548f00c0b7f109143c24ec2ec345355");
}
@Test
public void testVerifyExternalAuthenticodeTimestamp() throws Exception {
// The certificates in the signature consist in a mix of signing and timestamping certificates.
// This test ensures Jsign picks the right certificates for each purpose
testVerifyDetachedSignature("authenticode-timestamp.p7s", SHA1, "a75c5f46e6404e0a830f1b1ef18cc5444f6725d5");
}
@Test
public void testVerifyAuthenticodeTimestampContentTypeTSTInfo() throws Exception {
// The content-type of the timestamp is id-ct-TSTInfo (1.2.840.113549.1.9.16.1.4) instead of id-data (1.2.840.113549.1.7.1)
testVerifyDetachedSignature("authenticode-timestamp-content-type-tstinfo.p7s", SHA256, "724dd6d7ce8b9661a44dd4304c37d98e5223fb62d558533f81e2147d35d06d7d");
}
@Test
public void testVerifyAuthenticodeTimestampRawDigest() throws Exception {
// The signed content of the timestamp is the raw MD5 digest of the signature with the PKCS#1 v1.5 padding,
// instead of the digest wrapped in a DigestInfo structure. This signature also features a root timestamping
// certificate that is not downloadable through an AIA link.
testVerifyDetachedSignature("authenticode-timestamp-raw-digest.p7s", SHA1, "cef117bfd24f2bad41358e7fd2fe9a8a061d37e9");
}
@Test
@Ignore
public void testVerifyRFC3161TimestampSignatureError() throws Exception {
// The timestamp signature in not valid according to Bouncy Castle (it throws an TSPValidationException
// "signature not created by certificate", because the actual digest doesn't match the decrypted value)
// but Windows considers it valid. This signature comes from Acrobat Reader
// (C:\Program Files\Adobe\Acrobat DC\Acrobat\RDCNotificationClient\RDCNotificationClient.appx)
testVerifyDetachedSignature("rfc3161-timestamp-signature-error.p7s", SHA256,
"41505058" + // APPX
"41585043" + // AXPC
"776877fef011e82dc6b1c60dc665a6bda6b2156ed0b4171298948108bea63a97" +
"41584344" + // AXCD
"beea91ebb070800588bf1d0a3bfe2ff67c37c24c9fd8175852a6916bc06f5081" +
"41584354" + // AXCT
"e0e9f4462f5ba2695d196d7c9f0b14d9ef0809ec68835c78ff0a5ab8ea93e7d0" +
"4158424d" + // AXBM
"1a7ce307df343640298e2661120b8dec719ab74d534af98c6aabddd4b1dd4cb3" +
"41584349" + // AXCI
"cd9a896cd3f363664f5597a1d5f8198672172e3b8830bcdba7a75ed6cf92438a");
}
@Test
public void testVerifyRFC3161TimestampNonCriticalEKU() throws Exception {
// The timestamp token was signed by a certificate with a non-critical Extended Key Usage extension.
// This is accepted by Windows but deviates from the RFC 3161 specification.
testVerifyDetachedSignature("rfc3161-timestamp-non-critical-eku.p7s", SHA256, "fee8611e65296862facea47bc8f3732074c98401010ecf4d11a0e0b2c762e1ba");
}
@Test
public void testVerifyUnsupportedCriticalExtension() throws Exception {
// The signing certificate has an unsupported critical "Certificate Application Policy" extension (1.3.6.1.4.1.311.21.10)
testVerifyDetachedSignature("rfc3161-timestamp-non-critical-eku.p7s", SHA256, "fee8611e65296862facea47bc8f3732074c98401010ecf4d11a0e0b2c762e1ba");
}
@Test
public void testVerifyLifetimeSigningEKU() throws Exception {
// The signing certificate has the lifetime-signing EKU, the timestamp must not be checked
try (MockSignable signable = new MockSignable()) {
signable.attachSignatures(new File("target/test-classes/signatures/lifetime-signing-eku.p7s"));
signable.setDigest(SHA256, "d04b881fbbb83f4ab7b13e3dbf6472294216a13e5c624861a6d8a98088e7089c");
SignatureVerifier verifier = new SignatureVerifier();
VerificationResult result = verifier.verify(signable);
assertFalse("valid signature", result.isValid());
assertEquals("verification rule", "Timestamp", result.getSignatureVerifications().get(0).getChecks().get(1).getRule());
assertEquals("verification status", SKIPPED, result.getSignatureVerifications().get(0).getChecks().get(1).getStatus());
assertEquals("verification message", "Certificate has the lifetime signing key purpose, timestamp is ignored", result.getSignatureVerifications().get(0).getChecks().get(1).getMessage());
}
}
private void testVerifyDetachedSignature(String filename, DigestAlgorithm digestAlgorithm, String digest) throws Exception {
try (MockSignable signable = new MockSignable()) {
signable.attachSignatures(new File("target/test-classes/signatures/" + filename));
signable.setDigest(digestAlgorithm, digest);
testVerify(signable);
}
}
}