Created
September 11, 2021 15:45
-
-
Save Bricktricker/30ef77ea31cea9c4ccf4ad7811d819a5 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.nio.charset.StandardCharsets; | |
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.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.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) { | |
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(e -> ((Attributes.Name)e.getKey()).toString().endsWith(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().replace(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)); | |
} | |
boolean manifestCorrect = sfMainAttributes.entrySet() | |
.stream() | |
.filter(entry -> ((Attributes.Name)entry.getKey()).toString().endsWith(DIGEST_MANIFEST)) | |
.anyMatch(e -> checkHash(e, manifestBytes, DIGEST_MANIFEST)); | |
//validate x-Digest-Manifest-Main-Attributes if needed | |
if(!manifestCorrect) { | |
Attributes manifestMainAttributes = this.manifest.getMainAttributes(); | |
boolean checkMainAttributes = sfMainAttributes.entrySet() | |
.stream() | |
.anyMatch(entry -> ((Attributes.Name)entry.getKey()).toString().endsWith(DIGEST_MANIFEST_MAIN_ATTRIBUTES)); | |
if(checkMainAttributes) { | |
byte[] mainAttributesBytes = toByteArray(manifestMainAttributes); | |
boolean mainAttributesCorrect = sfMainAttributes.entrySet() | |
.stream() | |
.filter(entry -> ((Attributes.Name)entry.getKey()).toString().endsWith(DIGEST_MANIFEST_MAIN_ATTRIBUTES)) | |
.anyMatch(e -> checkHash(e, mainAttributesBytes, DIGEST_MANIFEST_MAIN_ATTRIBUTES)); | |
if(!mainAttributesCorrect) { | |
return false; | |
} | |
} | |
//validate the hashes in the .SF file | |
manifestCorrect = sfManifest.getEntries() | |
.entrySet() | |
.stream() | |
.allMatch(entry -> { | |
Attributes attributes = new Attributes(); | |
attributes.put(new Attributes.Name("Name"), entry.getKey()); | |
attributes.putAll(this.manifest.getAttributes(entry.getKey())); | |
byte[] entryBytes = toByteArray(attributes); | |
return entry.getValue().entrySet() | |
.stream() | |
.filter(e -> ((Attributes.Name)e.getKey()).toString().endsWith(DIGEST)) | |
.anyMatch(e -> checkHash(e, entryBytes, DIGEST)); | |
}); | |
} | |
return manifestCorrect; | |
} | |
private static boolean checkHash(Entry<Object, Object> entry, byte[] b, String repaceStr) { | |
String algo = ((Attributes.Name)entry.getKey()).toString().replace(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); | |
} | |
} | |
/** | |
* converts the given attributes into a byte array. | |
* because Attributes#write(DataOutputStream) is not visible we need to implement this ourself. | |
* Partially taken from the openjdk implementation | |
*/ | |
private static byte[] toByteArray(Attributes attributes) { | |
ByteArrayOutputStream baos = new ByteArrayOutputStream(); | |
attributes.forEach((keyObj, valObj) -> { | |
String key = ((Attributes.Name)keyObj).toString(); | |
String value = (String)valObj; | |
String line = key + ": " + value; | |
byte[] lineBytes = line.getBytes(StandardCharsets.UTF_8); | |
int length = lineBytes.length; | |
baos.write(lineBytes[0]); | |
int pos = 1; | |
while (length - pos > 71) { | |
baos.write(lineBytes, pos, 71); | |
pos += 71; | |
baos.write('\r'); baos.write('\n'); | |
baos.write(' '); | |
} | |
baos.write(lineBytes, pos, length - pos); | |
baos.write('\r'); baos.write('\n'); | |
}); | |
return baos.toByteArray(); | |
} | |
// 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 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(); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment