Skip to content

Instantly share code, notes, and snippets.

@Bricktricker
Created September 11, 2021 15:45
Show Gist options
  • Save Bricktricker/30ef77ea31cea9c4ccf4ad7811d819a5 to your computer and use it in GitHub Desktop.
Save Bricktricker/30ef77ea31cea9c4ccf4ad7811d819a5 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.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