From: Felix Dörre Date: Sat, 29 Jul 2017 21:12:54 +0000 (+0200) Subject: add: process to report compromised certificates X-Git-Url: https://code.wpia.club/?p=gigi.git;a=commitdiff_plain;h=11786136386ee067ed7a17b25bc07b3cd257ecb0 add: process to report compromised certificates Change-Id: I0f124a48ea18740d19fc413dd99b9a69bd1eb33e --- diff --git a/src/club/wpia/gigi/Gigi.java b/src/club/wpia/gigi/Gigi.java index f4e3f2ba..19818767 100644 --- a/src/club/wpia/gigi/Gigi.java +++ b/src/club/wpia/gigi/Gigi.java @@ -67,6 +67,7 @@ import club.wpia.gigi.pages.admin.support.SupportEnterTicketPage; import club.wpia.gigi.pages.admin.support.SupportUserDetailsPage; import club.wpia.gigi.pages.error.AccessDenied; import club.wpia.gigi.pages.error.PageNotFound; +import club.wpia.gigi.pages.main.KeyCompromisePage; import club.wpia.gigi.pages.main.RegisterPage; import club.wpia.gigi.pages.orga.CreateOrgPage; import club.wpia.gigi.pages.orga.ViewOrgPage; @@ -141,6 +142,7 @@ public final class Gigi extends HttpServlet { putPage(StatisticsRoles.PATH, new StatisticsRoles(), mainMenu); putPage("/about", new AboutPage(), mainMenu); putPage(RegisterPage.PATH, new RegisterPage(), mainMenu); + putPage(KeyCompromisePage.PATH, new KeyCompromisePage(), mainMenu); putPage("/secure", new TestSecure(), null); putPage(Verify.PATH, new Verify(), null); diff --git a/src/club/wpia/gigi/pages/main/KeyCompromiseForm.java b/src/club/wpia/gigi/pages/main/KeyCompromiseForm.java new file mode 100644 index 00000000..197f24da --- /dev/null +++ b/src/club/wpia/gigi/pages/main/KeyCompromiseForm.java @@ -0,0 +1,188 @@ +package club.wpia.gigi.pages.main; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.UnsupportedEncodingException; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.Signature; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.interfaces.RSAPrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Arrays; +import java.util.Base64; +import java.util.Map; + +import javax.servlet.http.HttpServletRequest; + +import club.wpia.gigi.GigiApiException; +import club.wpia.gigi.dbObjects.Certificate; +import club.wpia.gigi.dbObjects.Certificate.CertificateStatus; +import club.wpia.gigi.dbObjects.Job; +import club.wpia.gigi.localisation.Language; +import club.wpia.gigi.output.template.Form; +import club.wpia.gigi.output.template.Template; +import club.wpia.gigi.output.template.TranslateCommand; +import club.wpia.gigi.util.PEM; +import club.wpia.gigi.util.RandomToken; +import club.wpia.gigi.util.RateLimit; +import club.wpia.gigi.util.RateLimit.RateLimitException; + +public class KeyCompromiseForm extends Form { + + private static final Template t = new Template(KeyCompromiseForm.class.getResource("KeyCompromiseForm.templ")); + + // 50 per 5 min + public static final RateLimit RATE_LIMIT = new RateLimit(50, 5 * 60 * 1000); + + private final String challenge; + + public static final String CHALLENGE_PREFIX = "This private key has been compromised. Challenge: "; + + public static final TranslateCommand NOT_LOADED = new TranslateCommand("Certificate could not be loaded"); + + public static final TranslateCommand NOT_FOUND = new TranslateCommand("Certificate to revoke not found"); + + public KeyCompromiseForm(HttpServletRequest hsr) { + super(hsr); + challenge = RandomToken.generateToken(32); + } + + @Override + public SubmissionResult submit(HttpServletRequest req) throws GigiApiException { + if (RATE_LIMIT.isLimitExceeded(req.getRemoteAddr())) { + throw new RateLimitException(); + } + Certificate c = null; + X509Certificate cert = null; + String serial = req.getParameter("serial"); + String certData = req.getParameter("cert"); + if (serial != null && !serial.isEmpty()) { + c = fetchCertificate(serial); + try { + cert = c.cert(); + } catch (IOException e) { + throw new PermamentFormException(new GigiApiException(NOT_LOADED)); + } catch (GeneralSecurityException e) { + throw new PermamentFormException(new GigiApiException(NOT_LOADED)); + } + } + if (certData != null && !certData.isEmpty()) { + X509Certificate c0; + byte[] supplied; + try { + supplied = PEM.decode("CERTIFICATE", certData); + c0 = (X509Certificate) CertificateFactory.getInstance("X509").generateCertificate(new ByteArrayInputStream(supplied)); + } catch (IllegalArgumentException e1) { + throw new PermamentFormException(new GigiApiException("Your certificate could not be parsed")); + } catch (CertificateException e1) { + throw new PermamentFormException(new GigiApiException("Your certificate could not be parsed")); + } + try { + String ser = c0.getSerialNumber().toString(16); + c = fetchCertificate(ser); + cert = c.cert(); + if ( !Arrays.equals(supplied, cert.getEncoded())) { + throw new PermamentFormException(new GigiApiException(NOT_FOUND)); + } + } catch (IOException e) { + throw new PermamentFormException(new GigiApiException(NOT_LOADED)); + } catch (GeneralSecurityException e) { + throw new PermamentFormException(new GigiApiException(NOT_LOADED)); + } + } + if (c == null) { + throw new PermamentFormException(new GigiApiException("No certificate identification information provided")); + } + if (c.getStatus() == CertificateStatus.REVOKED) { + return new SuccessMessageResult(new TranslateCommand("Certificate had already been revoked")); + } + String inSig = req.getParameter("signature"); + byte[] signature = null; + if (inSig != null && !inSig.isEmpty()) { + try { + signature = Base64.getDecoder().decode(inSig); + } catch (IllegalArgumentException e) { + throw new PermamentFormException(new GigiApiException("Signature is malformed")); + } + } + String priv = req.getParameter("priv"); + if (signature == null && priv != null && !priv.isEmpty()) { + try { + PKCS8EncodedKeySpec k = new PKCS8EncodedKeySpec(PEM.decode("PRIVATE KEY", priv)); + RSAPrivateKey pk = (RSAPrivateKey) KeyFactory.getInstance("RSA").generatePrivate(k); + signature = sign(pk, challenge); + } catch (IllegalArgumentException e) { + throw new PermamentFormException(new GigiApiException("Private Key is malformed")); + } catch (GeneralSecurityException e) { + throw new PermamentFormException(new GigiApiException("Private Key is malformed")); + } + } + if (signature == null) { + throw new PermamentFormException(new GigiApiException("No verification provided.")); + } + + try { + Signature sig = Signature.getInstance("SHA256withRSA"); + sig.initVerify(cert.getPublicKey()); + sig.update(CHALLENGE_PREFIX.getBytes("UTF-8")); + sig.update(challenge.getBytes("UTF-8")); + if ( !sig.verify(signature)) { + throw new PermamentFormException(new GigiApiException("Verification does not match.")); + } + } catch (GeneralSecurityException e) { + throw new PermamentFormException(new GigiApiException("Wasn't able to generate signature.")); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + Job j = c.revoke(challenge, Base64.getEncoder().encodeToString(signature), ""); + if ( !j.waitFor(60000)) { + throw new PermamentFormException(new GigiApiException("Revocation timed out.")); + } + if (c.getStatus() != CertificateStatus.REVOKED) { + throw new PermamentFormException(new GigiApiException("Revocation failed.")); + } + return new SuccessMessageResult(new TranslateCommand("Certificate is revoked.")); + } + + public static byte[] sign(PrivateKey pk, String challenge) throws GeneralSecurityException { + byte[] signature; + Signature sig = Signature.getInstance("SHA256withRSA"); + sig.initSign(pk); + try { + sig.update(CHALLENGE_PREFIX.getBytes("UTF-8")); + sig.update(challenge.getBytes("UTF-8")); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + signature = sig.sign(); + return signature; + } + + private Certificate fetchCertificate(String serial) { + Certificate c; + serial = serial.trim().toLowerCase(); + int idx = 0; + while (idx < serial.length() && serial.charAt(idx) == '0') { + idx++; + } + serial = serial.substring(idx); + c = Certificate.getBySerial(serial); + if (c == null) { + throw new PermamentFormException(new GigiApiException(NOT_FOUND)); + } + return c; + } + + @Override + protected void outputContent(PrintWriter out, Language l, Map vars) { + vars.put("challenge", challenge); + vars.put("challengePrefix", CHALLENGE_PREFIX); + t.output(out, l, vars); + } + +} diff --git a/src/club/wpia/gigi/pages/main/KeyCompromiseForm.templ b/src/club/wpia/gigi/pages/main/KeyCompromiseForm.templ new file mode 100644 index 00000000..e75d2717 --- /dev/null +++ b/src/club/wpia/gigi/pages/main/KeyCompromiseForm.templ @@ -0,0 +1,45 @@ +

+ + + +

+ +

+ + + + +

+

+ +printf '%s' '' | openssl dgst -sha256 -sign priv.key | base64 + +

+ + + + + + + + + + + + + + + + + + + +
: + + + +
: + + + +
diff --git a/src/club/wpia/gigi/pages/main/KeyCompromisePage.java b/src/club/wpia/gigi/pages/main/KeyCompromisePage.java new file mode 100644 index 00000000..c36b1763 --- /dev/null +++ b/src/club/wpia/gigi/pages/main/KeyCompromisePage.java @@ -0,0 +1,30 @@ +package club.wpia.gigi.pages.main; + +import java.io.IOException; +import java.util.HashMap; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import club.wpia.gigi.pages.ManagedFormPage; +import club.wpia.gigi.util.AuthorizationContext; + +public class KeyCompromisePage extends ManagedFormPage { + + public static final String PATH = "/keyCompromise"; + + public KeyCompromisePage() { + super("Report Key Compromise", KeyCompromiseForm.class); + } + + @Override + public boolean isPermitted(AuthorizationContext ac) { + return true; + } + + @Override + public void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { + new KeyCompromiseForm(req).output(resp.getWriter(), getLanguage(req), new HashMap()); + } + +} diff --git a/tests/club/wpia/gigi/pages/main/KeyCompromiseTest.java b/tests/club/wpia/gigi/pages/main/KeyCompromiseTest.java new file mode 100644 index 00000000..1074a519 --- /dev/null +++ b/tests/club/wpia/gigi/pages/main/KeyCompromiseTest.java @@ -0,0 +1,198 @@ +package club.wpia.gigi.pages.main; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.*; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLEncoder; +import java.security.GeneralSecurityException; +import java.security.KeyPair; +import java.security.PrivateKey; +import java.util.Base64; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.hamcrest.CoreMatchers; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; + +import club.wpia.gigi.GigiApiException; +import club.wpia.gigi.dbObjects.Certificate; +import club.wpia.gigi.dbObjects.Certificate.CertificateStatus; +import club.wpia.gigi.dbObjects.Digest; +import club.wpia.gigi.dbObjects.Job; +import club.wpia.gigi.pages.account.certs.CertificateRequest; +import club.wpia.gigi.testUtils.ClientTest; +import club.wpia.gigi.testUtils.IOUtils; +import club.wpia.gigi.util.AuthorizationContext; +import club.wpia.gigi.util.PEM; + +@RunWith(Parameterized.class) +public class KeyCompromiseTest extends ClientTest { + + private static class TestParameters { + + private final String query; + + private final String error; + + public TestParameters(String query, String error) { + this.query = query; + this.error = error; + } + + public String getError() { + return error; + } + + public String getQuery() { + return query; + } + + @Override + public String toString() { + return query + ": " + error; + } + } + + private Certificate cert; + + private String serial; + + private PrivateKey priv; + + private TestParameters pm; + + public KeyCompromiseTest(TestParameters pm) throws GeneralSecurityException, IOException, GigiApiException, InterruptedException { + this.pm = pm; + KeyPair kp = generateKeypair(); + priv = kp.getPrivate(); + String csr = generatePEMCSR(kp, "CN=test"); + CertificateRequest cr = new CertificateRequest(new AuthorizationContext(u, u), csr); + cr.update(CertificateRequest.DEFAULT_CN, Digest.SHA512.toString(), "client", null, null, "email:" + email + "\n"); + cert = cr.draft(); + Job j = cert.issue(null, "2y", u); + await(j); + serial = cert.getSerial(); + } + + @Parameters(name = "{0}") + public static Object[][] getParams() { + return new Object[][] { + params("serial=%serial&priv=%priv", null),// serial+key + params("serial=0000%serial&priv=%priv", null),// leading Zeros + params("serial=0000%Serial&priv=%priv", null),// upper case + params("cert=%cert&priv=%priv", null),// cert+key + params("serial=%serial&signature=%signature", null), + // Zero serial + params("serial=0000&priv=%priv", KeyCompromiseForm.NOT_FOUND.getRaw()), + params("serial=0lkd&priv=%priv", KeyCompromiseForm.NOT_FOUND.getRaw()), + // tampered cert + params("cert=%tamperedCert&priv=%priv", "not be parsed"), + params("cert=%cert&priv=%tamperedPriv", "Private Key is malformed"), + params("serial=1&priv=%priv", KeyCompromiseForm.NOT_FOUND.getRaw()), + params("serial=1%serial&priv=%priv", KeyCompromiseForm.NOT_FOUND.getRaw()), + // missing certificate identification + params("serial=&cert=&priv=%priv", "identification"), + params("cert=&priv=%priv", "identification"), + params("serial=&priv=%priv", "identification"), + params("priv=%priv", "identification"), + // sign missing + params("serial=%serial&priv=&signature=", "No verification"), + params("serial=%serial&signature=", "No verification"), + params("serial=%serial&priv=", "No verification"), + params("serial=%serial", "No verification"), + params("cert=%cert&signature=%tamperedSignature", "Verification does not match"), + + params("cert=-_&signature=%signature", "certificate could not be parsed"), + params("cert=%cert&signature=-_", "Signature is malformed"), + params("cert=%cert&priv=-_", "Private Key is malformed"), + }; + } + + private static Object[] params(String query, String error) { + return new Object[] { + new TestParameters(query, error) + }; + } + + private String getQuery(String data) { + String cData = null; + { + Pattern challenge = Pattern.compile(" data-challenge=\"([a-zA-Z0-9]+)\""); + Matcher m = challenge.matcher(data); + if (m.find()) { + cData = m.group(1); + } + } + try { + byte[] privKeyData = priv.getEncoded(); + String privKey = URLEncoder.encode(PEM.encode("PRIVATE KEY", privKeyData), "UTF-8"); + privKeyData[0]++; + String privKeyTampered = URLEncoder.encode(PEM.encode("PRIVATE KEY", privKeyData), "UTF-8"); + byte[] tampered = cert.cert().getEncoded(); + tampered[0]++; + String query = pm.getQuery(); + query = query.replace("%serial", serial.toLowerCase())// + .replace("%Serial", serial.toUpperCase())// + .replace("%priv", privKey)// + .replace("%cert", URLEncoder.encode(PEM.encode("CERTIFICATE", cert.cert().getEncoded()), "UTF-8"))// + .replace("%tamperedCert", URLEncoder.encode(PEM.encode("CERTIFICATE", tampered), "UTF-8"))// + .replace("%tamperedPriv", privKeyTampered); + if (cData != null) { + byte[] sigRaw = KeyCompromiseForm.sign(priv, cData); + String sigData = URLEncoder.encode(Base64.getEncoder().encodeToString(sigRaw), "UTF-8"); + sigRaw[0]++; + query = query.replace("%signature", sigData); + query = query.replace("%tamperedSignature", URLEncoder.encode(Base64.getEncoder().encodeToString(sigRaw), "UTF-8")); + } + return query; + } catch (IOException e) { + throw new Error(e); + } catch (GeneralSecurityException e) { + throw new Error(e); + } + } + + @Test + public void testExecution() throws IOException, InterruptedException, GigiApiException, GeneralSecurityException { + URLConnection uc = new URL("https://" + getServerName() + KeyCompromisePage.PATH).openConnection(); + String cookie = stripCookie(uc.getHeaderField("Set-Cookie")); + String content = IOUtils.readURL(uc); + String csrf = getCSRF(0, content); + + uc = new URL("https://" + getServerName() + KeyCompromisePage.PATH).openConnection(); + cookie(uc, cookie); + uc.setDoOutput(true); + OutputStream os = uc.getOutputStream(); + os.write(("csrf=" + URLEncoder.encode(csrf, "UTF-8") + "&" // + + getQuery(content)// + ).getBytes("UTF-8")); + os.flush(); + HttpURLConnection huc = (HttpURLConnection) uc; + + String result = IOUtils.readURL(huc); + String error = pm.getError(); + if (error == null) { + assertThat(result, hasNoError()); + assertRevoked(result); + } else if ("error".equals(error)) { + assertThat(result, hasError()); + assertNotEquals(CertificateStatus.REVOKED, cert.getStatus()); + } else { + assertThat(fetchStartErrorMessage(result), CoreMatchers.containsString(error)); + assertNotEquals(CertificateStatus.REVOKED, cert.getStatus()); + } + } + + private void assertRevoked(String result) { + assertThat(result, CoreMatchers.containsString("Certificate is revoked")); + assertEquals(CertificateStatus.REVOKED, cert.getStatus()); + } +}