Skip to content

Instantly share code, notes, and snippets.

@Bricktricker
Created September 13, 2021 16:41
Show Gist options
  • Save Bricktricker/4b6926e7c705bdfcadacf1aea3a4d7e6 to your computer and use it in GitHub Desktop.
Save Bricktricker/4b6926e7c705bdfcadacf1aea3a4d7e6 to your computer and use it in GitHub Desktop.
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.SignatureException;
import java.util.Arrays;
import java.util.Base64;
import java.util.HashMap;
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 sun.security.pkcs.PKCS7;
import sun.security.pkcs.SignerInfo;
/**
* 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 JarVerifier 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;
/**
* 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 JarVerifier(File file) throws IOException {
super(file);
List<ZipEntry> signatureFiles = this.stream()
.filter(ze -> {
String name = ze.getName().toUpperCase(Locale.ENGLISH);
return name.startsWith("META-INF/") && 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 JarVerifier#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 JarVerifier#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 {
PKCS7 cert = new PKCS7(this.getInputStreamNoCheck(entry));
SignerInfo[] signer = cert.verify(toByteArray(this.getInputStreamNoCheck(sfFile)));
if(signer == null || signer.length == 0) {
return Optional.empty(); //signature not valid
}
List<CodeSigner> codeSigners = new ArrayList<>();
CertificateFactory certificateFactory = CertificateFactory.getInstance("X509");
for(int i = 0; i < signer.length; i++) {
SignerInfo info = signer[i];
ArrayList<X509Certificate> chain = info.getCertificateChain(cert);
CertPath certChain = certificateFactory.generateCertPath(chain);
codeSigners.add(new CodeSigner(certChain, info.getTimestamp()));
}
return Optional.of(codeSigners);
}catch(NoSuchAlgorithmException | SignatureException | IOException | CertificateException 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