add: process to report compromised certificates
authorFelix Dörre <felix@dogcraft.de>
Sat, 29 Jul 2017 21:12:54 +0000 (23:12 +0200)
committerFelix Dörre <felix@dogcraft.de>
Tue, 10 Oct 2017 19:24:22 +0000 (21:24 +0200)
Change-Id: I0f124a48ea18740d19fc413dd99b9a69bd1eb33e

src/club/wpia/gigi/Gigi.java
src/club/wpia/gigi/pages/main/KeyCompromiseForm.java [new file with mode: 0644]
src/club/wpia/gigi/pages/main/KeyCompromiseForm.templ [new file with mode: 0644]
src/club/wpia/gigi/pages/main/KeyCompromisePage.java [new file with mode: 0644]
tests/club/wpia/gigi/pages/main/KeyCompromiseTest.java [new file with mode: 0644]

index f4e3f2b..1981876 100644 (file)
@@ -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 (file)
index 0000000..197f24d
--- /dev/null
@@ -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<String, Object> 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 (file)
index 0000000..e75d271
--- /dev/null
@@ -0,0 +1,45 @@
+<p>
+<?=_This form allows you to report a certificate whose private key has been compromised.?>
+<?=_You require to identify the certificate you need to report.?>
+<?=_You may upload the certificate (as PEM or DER) or may identify the certificate by serial.?>
+</p>
+
+<p>
+<?=_Additionally you need to prove that you have access to the private key.?>
+<?=_There are also two possibilities available here:?>
+<?=_Either you may upload the plain private key (as PEM or DER) or you may only sign a given message with the according private key.?>
+<?=_You may create a fitting signature with this command:?>
+</p>
+<p>
+<code data-challenge="<?=$challenge?>">
+printf '%s' '<?=$challengePrefix?><?=$challenge?>' | openssl dgst -sha256 -sign priv.key | base64
+</code>
+</p>
+
+<table class="table">
+  <tbody>
+  <tr>
+    <td><?=_Certificate?>: </td>
+    <td>
+      <textarea class="form-control" name="cert" rows="3" cols="40" placeholder="<?=_Certificate?>"></textarea>
+    </td>
+    <td><?=_or?></td>
+    <td>
+      <input type="text" class="form-control" name="serial" placeholder="<?=_Certificate Serial Number (hexadecimal)?>">
+    </td>
+  </tr>
+  <tr>
+    <td><?=_Private Key?>: </td>
+    <td>
+      <textarea class="form-control" name="priv" rows="3" cols="40" placeholder="<?=_Private Key?>"></textarea>
+    </td>
+    <td><?=_or?></td>
+    <td>
+      <input type="text" class="form-control" name="signature" placeholder="<?=_Signature?>">
+    </td>
+  </tr>
+  <tr>
+    <td colspan="4"><input class="btn btn-primary" type="submit" name="process" value="<?=_Next?>"></td>
+  </tr>
+  </tbody>
+</table>
diff --git a/src/club/wpia/gigi/pages/main/KeyCompromisePage.java b/src/club/wpia/gigi/pages/main/KeyCompromisePage.java
new file mode 100644 (file)
index 0000000..c36b176
--- /dev/null
@@ -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<String, Object>());
+    }
+
+}
diff --git a/tests/club/wpia/gigi/pages/main/KeyCompromiseTest.java b/tests/club/wpia/gigi/pages/main/KeyCompromiseTest.java
new file mode 100644 (file)
index 0000000..1074a51
--- /dev/null
@@ -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());
+    }
+}