package jenkins.util;
import com.trilead.ssh2.crypto.Base64;
import hudson.util.FormValidation;
import jenkins.model.Jenkins;
import net.sf.json.JSONObject;
import org.apache.commons.io.output.NullOutputStream;
import org.apache.commons.io.output.TeeOutputStream;
import org.jvnet.hudson.crypto.CertificateUtil;
import org.jvnet.hudson.crypto.SignatureOutputStream;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStreamWriter;
import java.security.DigestOutputStream;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.Signature;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.CertificateExpiredException;
import java.security.cert.CertificateFactory;
import java.security.cert.CertificateNotYetValidException;
import java.security.cert.TrustAnchor;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* @author Kohsuke Kawaguchi
* @since 1.482
*/
public class JSONSignatureValidator {
private final String name;
public JSONSignatureValidator(String name) {
this.name = name;
}
/**
* Verifies the signature in the update center data file.
*/
public FormValidation verifySignature(JSONObject o) throws IOException {
try {
FormValidation warning = null;
JSONObject signature = o.getJSONObject("signature");
if (signature.isNullObject()) {
return FormValidation.error("No signature block found in "+name);
}
o.remove("signature");
List<X509Certificate> certs = new ArrayList<X509Certificate>();
{// load and verify certificates
CertificateFactory cf = CertificateFactory.getInstance("X509");
for (Object cert : signature.getJSONArray("certificates")) {
X509Certificate c = (X509Certificate) cf.generateCertificate(new ByteArrayInputStream(Base64.decode(cert.toString().toCharArray())));
try {
c.checkValidity();
} catch (CertificateExpiredException e) { // even if the certificate isn't valid yet, we'll proceed it anyway
warning = FormValidation.warning(e,String.format("Certificate %s has expired in %s",cert.toString(),name));
} catch (CertificateNotYetValidException e) {
warning = FormValidation.warning(e,String.format("Certificate %s is not yet valid in %s",cert.toString(),name));
}
LOGGER.log(Level.FINE, "Add certificate found in json doc: \r\n\tsubjectDN: {0}\r\n\tissuer: {1}", new Object[]{c.getSubjectDN(), c.getIssuerDN()});
certs.add(c);
}
CertificateUtil.validatePath(certs, loadTrustAnchors(cf));
}
// this is for computing a digest to check sanity
MessageDigest sha1 = MessageDigest.getInstance("SHA1");
DigestOutputStream dos = new DigestOutputStream(new NullOutputStream(),sha1);
// this is for computing a signature
Signature sig = Signature.getInstance("SHA1withRSA");
if (certs.isEmpty()) {
return FormValidation.error("No certificate found in %s. Cannot verify the signature", name);
} else {
sig.initVerify(certs.get(0));
}
SignatureOutputStream sos = new SignatureOutputStream(sig);
// until JENKINS-11110 fix, UC used to serve invalid digest (and therefore unverifiable signature)
// that only covers the earlier portion of the file. This was caused by the lack of close() call
// in the canonical writing, which apparently leave some bytes somewhere that's not flushed to
// the digest output stream. This affects Jenkins [1.424,1,431].
// Jenkins 1.432 shipped with the "fix" (1eb0c64abb3794edce29cbb1de50c93fa03a8229) that made it
// compute the correct digest, but it breaks all the existing UC json metadata out there. We then
// quickly discovered ourselves in the catch-22 situation. If we generate UC with the correct signature,
// it'll cut off [1.424,1.431] from the UC. But if we don't, we'll cut off [1.432,*).
//
// In 1.433, we revisited 1eb0c64abb3794edce29cbb1de50c93fa03a8229 so that the original "digest"/"signature"
// pair continues to be generated in a buggy form, while "correct_digest"/"correct_signature" are generated
// correctly.
//
// Jenkins should ignore "digest"/"signature" pair. Accepting it creates a vulnerability that allows
// the attacker to inject a fragment at the end of the json.
o.writeCanonical(new OutputStreamWriter(new TeeOutputStream(dos,sos),"UTF-8")).close();
// did the digest match? this is not a part of the signature validation, but if we have a bug in the c14n
// (which is more likely than someone tampering with update center), we can tell
String computedDigest = new String(Base64.encode(sha1.digest()));
String providedDigest = signature.optString("correct_digest");
if (providedDigest==null) {
return FormValidation.error("No correct_digest parameter in "+name+". This metadata appears to be old.");
}
if (!computedDigest.equalsIgnoreCase(providedDigest)) {
String msg = "Digest mismatch: computed=" + computedDigest + " vs expected=" + providedDigest + " in " + name;
if (LOGGER.isLoggable(Level.SEVERE)) {
LOGGER.severe(msg);
LOGGER.severe(o.toString(2));
}
return FormValidation.error(msg);
}
String providedSignature = signature.getString("correct_signature");
if (!sig.verify(Base64.decode(providedSignature.toCharArray()))) {
return FormValidation.error("Signature in the update center doesn't match with the certificate in "+name);
}
if (warning!=null) return warning;
return FormValidation.ok();
} catch (GeneralSecurityException e) {
return FormValidation.error(e,"Signature verification failed in "+name);
}
}
protected Set<TrustAnchor> loadTrustAnchors(CertificateFactory cf) throws IOException {
// if we trust default root CAs, we end up trusting anyone who has a valid certificate,
// which isn't useful at all
Set<TrustAnchor> anchors = new HashSet<TrustAnchor>(); // CertificateUtil.getDefaultRootCAs();
Jenkins j = Jenkins.getInstance();
for (String cert : (Set<String>) j.servletContext.getResourcePaths("/WEB-INF/update-center-rootCAs")) {
if (cert.endsWith("/") || cert.endsWith(".txt")) {
continue; // skip directories also any text files that are meant to be documentation
}
InputStream in = j.servletContext.getResourceAsStream(cert);
if (in == null) continue; // our test for paths ending in / should prevent this from happening
Certificate certificate;
try {
certificate = cf.generateCertificate(in);
} catch (CertificateException e) {
LOGGER.log(Level.WARNING, String.format("Webapp resources in /WEB-INF/update-center-rootCAs are "
+ "expected to be either certificates or .txt files documenting the "
+ "certificates, but %s did not parse as a certificate. Skipping this "
+ "resource for now.",
cert), e);
continue;
} finally {
in.close();
}
try {
TrustAnchor certificateAuthority = new TrustAnchor((X509Certificate) certificate, null);
LOGGER.log(Level.FINE, "Add Certificate Authority {0}: {1}",
new Object[]{cert, (certificateAuthority.getTrustedCert() == null ? null : certificateAuthority.getTrustedCert().getSubjectDN())});
anchors.add(certificateAuthority);
} catch (IllegalArgumentException e) {
LOGGER.log(Level.WARNING,
String.format("The name constraints in the certificate resource %s could not be "
+ "decoded. Skipping this resource for now.",
cert), e);
}
}
File[] cas = new File(j.root, "update-center-rootCAs").listFiles();
if (cas!=null) {
for (File cert : cas) {
if (cert.isDirectory() || cert.getName().endsWith(".txt")) {
continue; // skip directories also any text files that are meant to be documentation
}
FileInputStream in = new FileInputStream(cert);
Certificate certificate;
try {
certificate = cf.generateCertificate(in);
} catch (CertificateException e) {
LOGGER.log(Level.WARNING, String.format("Files in %s are expected to be either "
+ "certificates or .txt files documenting the certificates, "
+ "but %s did not parse as a certificate. Skipping this file for now.",
cert.getParentFile().getAbsolutePath(),
cert.getAbsolutePath()), e);
continue;
} finally {
in.close();
}
try {
TrustAnchor certificateAuthority = new TrustAnchor((X509Certificate) certificate, null);
LOGGER.log(Level.FINE, "Add Certificate Authority {0}: {1}",
new Object[]{cert, (certificateAuthority.getTrustedCert() == null ? null : certificateAuthority.getTrustedCert().getSubjectDN())});
anchors.add(certificateAuthority);
} catch (IllegalArgumentException e) {
LOGGER.log(Level.WARNING,
String.format("The name constraints in the certificate file %s could not be "
+ "decoded. Skipping this file for now.",
cert.getAbsolutePath()), e);
}
}
}
return anchors;
}
private static final Logger LOGGER = Logger.getLogger(JSONSignatureValidator.class.getName());
}