Created
September 14, 2021 18:26
-
-
Save Bricktricker/467aabc8f4cc96929a3d2e80530c3152 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package jarverifier; | |
import java.io.ByteArrayInputStream; | |
import java.io.ByteArrayOutputStream; | |
import java.io.File; | |
import java.io.IOException; | |
import java.io.InputStream; | |
import java.io.UncheckedIOException; | |
import java.security.cert.CertPath; | |
import java.security.cert.CertificateException; | |
import java.security.cert.CertificateFactory; | |
import java.security.cert.X509Certificate; | |
import java.security.CodeSigner; | |
import java.security.MessageDigest; | |
import java.security.NoSuchAlgorithmException; | |
import java.security.Security; | |
import java.util.Arrays; | |
import java.util.Base64; | |
import java.util.Collection; | |
import java.util.HashMap; | |
import java.util.Iterator; | |
import java.util.List; | |
import java.util.Locale; | |
import java.util.Map; | |
import java.util.AbstractMap; | |
import java.util.ArrayList; | |
import java.util.Map.Entry; | |
import java.util.Optional; | |
import java.util.function.Predicate; | |
import java.util.jar.Attributes; | |
import java.util.jar.Manifest; | |
import java.util.stream.Collectors; | |
import java.util.zip.ZipEntry; | |
import java.util.zip.ZipException; | |
import java.util.zip.ZipFile; | |
import org.bouncycastle.cert.X509CertificateHolder; | |
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; | |
import org.bouncycastle.cms.CMSException; | |
import org.bouncycastle.cms.CMSProcessableByteArray; | |
import org.bouncycastle.cms.CMSSignedData; | |
import org.bouncycastle.cms.SignerInformation; | |
import org.bouncycastle.cms.SignerInformationStore; | |
import org.bouncycastle.cms.jcajce.JcaSimpleSignerInfoVerifierBuilder; | |
import org.bouncycastle.jce.provider.BouncyCastleProvider; | |
import org.bouncycastle.operator.OperatorCreationException; | |
import org.bouncycastle.util.Store; | |
/** | |
* A class that checks if the opened jar file is singned and if so, checks if the signature is valid. | |
* | |
* Can check for jars that are signed with DSA or RSA, with a PKCS7 signature. | |
* Only checks if the file contents were changed when they are read from the jar, not when the jar gets opened. | |
* This class does not understand the Magic Attribute in the Manifest, see: | |
* (https://docs.oracle.com/en/java/javase/16/docs/specs/jar/jar.html#the-magic-attribute) | |
*/ | |
public class JarVerifierBC extends ZipFile { | |
private static final String DIGEST_MANIFEST = "-Digest-Manifest"; | |
private static final String DIGEST_MANIFEST_MAIN_ATTRIBUTES = "-Digest-Manifest-Main-Attributes"; | |
private static final String DIGEST = "-Digest"; | |
private final boolean isSigned; | |
private final boolean isSignatureValid; | |
private final CodeSigner[] codeSigner; | |
private Manifest manifest; | |
static { | |
Security.addProvider(new BouncyCastleProvider()); | |
} | |
/** | |
* Opens the given file and checks if the jar file is signed. | |
* If so it checks if the signature is valid. | |
* | |
* @param file the JAR file to be opened for reading | |
* @throws IOException if an I/O error has occurred | |
* @throws ZipException if a ZIP format error has occurred | |
*/ | |
public JarVerifierBC(File file) throws IOException { | |
super(file); | |
List<ZipEntry> signatureFiles = this.stream() | |
.filter(ze -> { | |
// find all signature files | |
String name = ze.getName().toUpperCase(Locale.ENGLISH); | |
if(!name.startsWith("META-INF/")) { | |
return false; | |
} | |
if(name.lastIndexOf('/') > 9) { // in a sub-directory | |
return false; | |
} | |
return name.endsWith(".SF"); | |
}) | |
.collect(Collectors.toList()); | |
this.isSigned = !signatureFiles.isEmpty(); | |
if(this.isSigned) { | |
// Step 1: Verify the signature files | |
List<CodeSigner> allSigners = new ArrayList<>(); | |
for(int i = 0; i < signatureFiles.size(); i++) { | |
ZipEntry ze = signatureFiles.get(i); | |
Optional<List<CodeSigner>> signer = checkDigitalSignature(ze); | |
if(!signer.isPresent()) { | |
//signature not valid | |
this.isSignatureValid = false; | |
this.codeSigner = null; | |
return; | |
} | |
allSigners.addAll(signer.get()); | |
} | |
this.codeSigner = allSigners.toArray(new CodeSigner[allSigners.size()]); | |
this.isSignatureValid = signatureFiles.stream() | |
.anyMatch(ze -> { | |
try { | |
return validateManifest(ze); | |
}catch(IOException e) { | |
//FIXME: log error? | |
return false; | |
} | |
}); | |
}else { | |
this.isSignatureValid = false; | |
this.codeSigner = null; | |
} | |
} | |
/** | |
* Returns an InputStream for reading the contents of the specified | |
* zip file entry. | |
* | |
* If the jar is signed, the returned InputStream checks if the file contents have been changed after fully reading it. | |
*/ | |
@Override | |
public InputStream getInputStream(ZipEntry entry) throws IOException { | |
InputStream is = super.getInputStream(entry); | |
if(!this.isSigned) { | |
return is; | |
} | |
Attributes attr = this.manifest.getAttributes(entry.getName()); | |
if(attr == null) { | |
return is; | |
} | |
Optional<Map.Entry<String, String>> hash = attr.entrySet() | |
.stream() | |
.filter(attributeEndsWith(DIGEST)) | |
.map(e -> new AbstractMap.SimpleEntry<>(((Attributes.Name)e.getKey()).toString(), (String)e.getValue())) | |
.map(e -> (Map.Entry<String, String>)e) | |
.findAny(); | |
if(!hash.isPresent()) { | |
return is; | |
} | |
try { | |
String algo = hash.get().getKey().replaceAll("(?i)" + DIGEST, ""); | |
MessageDigest digest = MessageDigest.getInstance(algo); | |
byte[] targetHash = Base64.getDecoder().decode(hash.get().getValue()); | |
return new ValidatingInputStream(is, digest, targetHash); | |
}catch(NoSuchAlgorithmException e) { | |
throw new RuntimeException(e); | |
} | |
} | |
/** | |
* Same like {@link ZipFile#getInputStream(ZipEntry)}, but without checking if the file content has been changed. | |
* @param entry the jar file entry | |
* @return the input stream for reading the contents of the specified jar file entry | |
* @throws ZipException if a ZIP format error has occurred | |
* @throws IOException if an I/O error has occurred | |
* @throws IllegalStateException if the zip file has been closed | |
*/ | |
public InputStream getInputStreamNoCheck(ZipEntry entry) throws IOException { | |
return super.getInputStream(entry); | |
} | |
/** | |
* returns true if the opened jar is signed. | |
* @return true if jar is signed | |
*/ | |
public boolean isSigned() { | |
return this.isSigned; | |
} | |
/** | |
* returns true if the signature of opened jar is valid. | |
* Only call this if {@link JarVerifierBC#isSigned()} returns true. | |
* | |
* @return true if the signature of opened jar is valid | |
* @throws IllegalStateException if the jar is not signed | |
*/ | |
public boolean isSignatureValid() { | |
if(!isSigned) { | |
throw new IllegalStateException(); | |
} | |
return this.isSignatureValid; | |
} | |
/** | |
* returns an array of all code signers that have signed the jar. | |
* Only call this if {@link JarVerifierBC#isSigned()} returns true. | |
* | |
* @return an array of all code signers | |
* @throws IllegalStateException if the jar is not signed | |
*/ | |
public CodeSigner[] getCodeSigner() { | |
if(!isSigned) { | |
throw new IllegalStateException(); | |
} | |
return this.codeSigner; | |
} | |
private boolean validateManifest(ZipEntry sfEntry) throws IOException { | |
Manifest sfManifest = new Manifest(this.getInputStreamNoCheck(sfEntry)); | |
Attributes sfMainAttributes = sfManifest.getMainAttributes(); | |
ZipEntry manifestEntry = this.getEntry("META-INF/MANIFEST.MF"); | |
if(manifestEntry == null) { | |
return false; // Signing information are present in the jar, but not manifest file! | |
} | |
byte[] manifestBytes = toByteArray(this.getInputStreamNoCheck(manifestEntry)); | |
if(this.manifest == null) { | |
this.manifest = new Manifest(new ByteArrayInputStream(manifestBytes)); | |
} | |
// Step 2: if an x-Digest-Manifest attribute exists in the signature file, verify the value against a digest calculated over the entire manifest | |
boolean manifestCorrect = sfMainAttributes.entrySet() | |
.stream() | |
.filter(attributeEndsWith(DIGEST_MANIFEST)) | |
.anyMatch(e -> checkHash(e, manifestBytes, DIGEST_MANIFEST)); | |
// Step 3: If an x-Digest-Manifest attribute does not exist in the signature file or none of the digest values calculated in the previous step match, | |
// if we have a x-Digest-Manifest-Main-Attributes entry, verify the manifest main attributes | |
if(!manifestCorrect) { | |
boolean checkMainAttributes = sfMainAttributes.entrySet() | |
.stream() | |
.anyMatch(attributeEndsWith(DIGEST_MANIFEST_MAIN_ATTRIBUTES)); | |
int endOfMainAttributes = findEndOfAttributes(manifestBytes, 0); | |
if(checkMainAttributes) { | |
byte[] mainAttributesBytes = Arrays.copyOf(manifestBytes, endOfMainAttributes); | |
boolean mainAttributesCorrect = sfMainAttributes.entrySet() | |
.stream() | |
.filter(attributeEndsWith(DIGEST_MANIFEST_MAIN_ATTRIBUTES)) | |
.anyMatch(e -> checkHash(e, mainAttributesBytes, DIGEST_MANIFEST_MAIN_ATTRIBUTES)); | |
if(!mainAttributesCorrect) { | |
return false; | |
} | |
} | |
Map<String, Range> ranges = findAttributeRanges(Arrays.copyOfRange(manifestBytes, endOfMainAttributes, manifestBytes.length)); | |
//validate the hashes in the .SF file | |
manifestCorrect = sfManifest.getEntries() | |
.entrySet() | |
.stream() | |
.allMatch(entry -> { | |
Range range = ranges.get(entry.getKey()); | |
byte[] entryBytes = Arrays.copyOfRange(manifestBytes, range.begin + endOfMainAttributes, range.end + endOfMainAttributes); | |
boolean valid = entry.getValue().entrySet() | |
.stream() | |
.filter(attributeEndsWith(DIGEST)) | |
.anyMatch(e -> checkHash(e, entryBytes, DIGEST)); | |
return valid; | |
}); | |
} | |
return manifestCorrect; | |
} | |
private static boolean checkHash(Entry<Object, Object> entry, byte[] b, String repaceStr) { | |
String algo = ((Attributes.Name)entry.getKey()).toString().replaceAll("(?i)" + repaceStr, ""); | |
MessageDigest digest; | |
try { | |
digest = MessageDigest.getInstance(algo); | |
}catch(NoSuchAlgorithmException e) { | |
throw new RuntimeException(e); | |
} | |
byte[] hash = digest.digest(b); | |
byte[] signedHash = Base64.getDecoder().decode((String)entry.getValue()); | |
return Arrays.equals(hash, signedHash); | |
} | |
private Optional<List<CodeSigner>> checkDigitalSignature(ZipEntry sfFile) { | |
String dsaFile = sfFile.getName().replace(".SF", ".DSA"); | |
ZipEntry entry = this.getEntry(dsaFile); | |
if(entry == null) { | |
String rsaFile = sfFile.getName().replace(".SF", ".RSA"); | |
entry = this.getEntry(rsaFile); | |
if(entry == null) { | |
return Optional.empty(); // Signature file is present, but no signature validation file | |
} | |
} | |
try { | |
CMSProcessableByteArray signedContent = new CMSProcessableByteArray(toByteArray(this.getInputStreamNoCheck(sfFile))); | |
CMSSignedData signedData = new CMSSignedData(signedContent, this.getInputStreamNoCheck(entry)); | |
Store<X509CertificateHolder> certStore = signedData.getCertificates(); | |
SignerInformationStore signers = signedData.getSignerInfos(); | |
Iterator<SignerInformation> itr = signers.getSigners().iterator(); | |
List<CodeSigner> codeSigners = new ArrayList<>(); | |
CertificateFactory certificateFactory = CertificateFactory.getInstance("X509"); | |
while(itr.hasNext()) { | |
SignerInformation signer = itr.next(); | |
@SuppressWarnings("unchecked") | |
Collection<X509CertificateHolder> certCollection = certStore.getMatches(signer.getSID()); | |
List<X509Certificate> certificates = new ArrayList<>(); | |
Iterator<X509CertificateHolder> certItr = certCollection.iterator(); | |
while(certItr.hasNext()) { | |
X509CertificateHolder cert = certItr.next(); | |
if(signer.verify(new JcaSimpleSignerInfoVerifierBuilder().setProvider("BC").build(cert))) { | |
X509Certificate javaCert = new JcaX509CertificateConverter().getCertificate(cert); | |
certificates.add(javaCert); | |
}else { | |
return Optional.empty(); // signature not valid | |
} | |
} | |
CertPath certChain = certificateFactory.generateCertPath(certificates); | |
// Not sure how to get the timestamp, I haven't found a mod that uses Signature Timestamp Support | |
// https://docs.oracle.com/javase/7/docs/technotes/guides/security/time-of-signing.html | |
// But passing in null is valid | |
codeSigners.add(new CodeSigner(certChain, null)); | |
} | |
return Optional.of(codeSigners); | |
}catch(CertificateException | CMSException | IOException | OperatorCreationException e) { | |
throw new RuntimeException(e); | |
} | |
} | |
// When using Java 9+, you can use InputStream#readAllBytes() | |
private static byte[] toByteArray(InputStream is) { | |
ByteArrayOutputStream buffer = new ByteArrayOutputStream(); | |
int nRead; | |
byte[] data = new byte[16384]; | |
try { | |
while ((nRead = is.read(data, 0, data.length)) != -1) { | |
buffer.write(data, 0, nRead); | |
} | |
}catch(IOException e) { | |
throw new UncheckedIOException(e); | |
} | |
return buffer.toByteArray(); | |
} | |
private static Predicate<Entry<Object, Object>> attributeEndsWith(String str) { | |
String lowerStr = str.toLowerCase(Locale.ENGLISH); | |
return entry -> ((Attributes.Name)entry.getKey()).toString().toLowerCase(Locale.ENGLISH).endsWith(lowerStr); | |
} | |
/** | |
* Finds the end byte of the attributes block, beginning at index beginIndex of the specified manifest byte array | |
* | |
* @param manifest the manifest bytes | |
* @param beginIndex the first byte of the block, where you want to find the end from | |
* @return the last index + 1 from the requested attributes block | |
*/ | |
private static int findEndOfAttributes(byte[] manifest, int beginIndex) { | |
// To find out were the attribute block ends, we just need to search for two new lines without text between them. | |
// we use a simple state machine to find the two new lines | |
// states: | |
// 0: inside text | |
// 1: found text+ CR | |
// 2: found text+ LF | |
// 3: found CR LF | |
// 4: found a CR after state 3 | |
int state = 0; | |
for(int i = beginIndex; i < manifest.length; i++) { | |
byte b = manifest[i]; | |
if(b != '\r' && b != '\n') { | |
state = 0; | |
}else { | |
if(state == 0 && b == '\r') { | |
state = 1; | |
} else if(state == 0 && b == '\n') { | |
state = 2; | |
} else if(state == 1 && b == '\n') { | |
state = 3; | |
} else if(state == 1 && b == '\r') { | |
return i + 1; // found CR CR | |
} else if(state == 2 && b == '\n') { | |
return i + 1; // found LF LF | |
} else if(state == 3 && b == '\r') { | |
state = 4; | |
} else if(state == 4 && b == '\n') { | |
return i + 1; // found CR LF CR LF | |
} | |
} | |
} | |
throw new IllegalStateException("Could not find end of attributes"); | |
} | |
/** | |
* Builds a map where the attributes start and end. The input should be the bytes from the manifest | |
* WITHOUT the bytes from the main section. The ranges in the output map are relative to the input byte array. | |
* | |
* @param manifest the manifest bytes without the main section | |
* @return a map for every individual-section mapping the section name to the byte range it has in the input array | |
*/ | |
private static Map<String, Range> findAttributeRanges(byte[] manifest) { | |
Map<String, Range> ranges = new HashMap<>(); | |
int start = 0; | |
while(start < manifest.length) { | |
int end = findEndOfAttributes(manifest, start); | |
Manifest mf = null; | |
try { | |
mf = new Manifest(new ByteArrayInputStream(manifest, start, end)); | |
}catch(IOException e) {} | |
String name = mf.getMainAttributes().getValue("Name"); | |
ranges.put(name, new Range(start, end)); | |
start = end; | |
} | |
return ranges; | |
} | |
private static class ValidatingInputStream extends InputStream { | |
private final InputStream is; | |
private MessageDigest digest; | |
private byte[] targetHash; | |
public ValidatingInputStream(InputStream is, MessageDigest digest, byte[] targetHash) { | |
this.is = is; | |
this.digest = digest; | |
this.targetHash = targetHash; | |
} | |
private void checkHash() { | |
if(digest != null) { // only validate the hash when we reach the end the first time | |
byte[] computedHash = digest.digest(); | |
if(!Arrays.equals(computedHash, this.targetHash)) { | |
throw new RuntimeException("integrity check failed"); | |
} | |
digest = null; | |
targetHash = null; | |
} | |
} | |
@Override | |
public int read() throws IOException { | |
int v = is.read(); | |
if(v == -1) { | |
checkHash(); | |
}else { | |
digest.update((byte)v); | |
} | |
return v; | |
} | |
@Override | |
public int read(byte[] b, int off, int len) throws IOException { | |
int v = is.read(b, off, len); | |
if(v == -1) { | |
checkHash(); | |
}else { | |
digest.update(b, off, v); | |
} | |
return v; | |
} | |
@Override | |
public int available() throws IOException { | |
return is.available(); | |
} | |
@Override | |
public void close() throws IOException { | |
is.close(); | |
} | |
} | |
// Maybe use a record? | |
private static class Range { | |
public final int begin; | |
public final int end; | |
public Range(int begin, int end) { | |
this.begin = begin; | |
this.end = end; | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment