add: message while reporting private key compromise
authorFelix Dörre <felix@dogcraft.de>
Fri, 25 Aug 2017 22:52:48 +0000 (00:52 +0200)
committerFelix Dörre <felix@dogcraft.de>
Tue, 10 Oct 2017 19:24:23 +0000 (21:24 +0200)
Change-Id: I164ed07804c65e9e9396166d61e3cba645ae308e

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

index 197f24d..84af8c3 100644 (file)
@@ -15,6 +15,8 @@ import java.security.interfaces.RSAPrivateKey;
 import java.security.spec.PKCS8EncodedKeySpec;
 import java.util.Arrays;
 import java.util.Base64;
+import java.util.HashMap;
+import java.util.Locale;
 import java.util.Map;
 
 import javax.servlet.http.HttpServletRequest;
@@ -22,18 +24,25 @@ 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.CertificateOwner;
 import club.wpia.gigi.dbObjects.Job;
+import club.wpia.gigi.dbObjects.Organisation;
+import club.wpia.gigi.dbObjects.User;
 import club.wpia.gigi.localisation.Language;
 import club.wpia.gigi.output.template.Form;
+import club.wpia.gigi.output.template.MailTemplate;
 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;
+import club.wpia.gigi.util.ServerConstants;
 
 public class KeyCompromiseForm extends Form {
 
+    public static final String CONFIDENTIAL_MARKER = "*CONFIDENTIAL*";
+
     private static final Template t = new Template(KeyCompromiseForm.class.getResource("KeyCompromiseForm.templ"));
 
     // 50 per 5 min
@@ -47,6 +56,8 @@ public class KeyCompromiseForm extends Form {
 
     public static final TranslateCommand NOT_FOUND = new TranslateCommand("Certificate to revoke not found");
 
+    private static final MailTemplate revocationNotice = new MailTemplate(KeyCompromiseForm.class.getResource("RevocationNotice.templ"));
+
     public KeyCompromiseForm(HttpServletRequest hsr) {
         super(hsr);
         challenge = RandomToken.generateToken(32);
@@ -139,7 +150,52 @@ public class KeyCompromiseForm extends Form {
         } catch (UnsupportedEncodingException e) {
             throw new RuntimeException(e);
         }
-        Job j = c.revoke(challenge, Base64.getEncoder().encodeToString(signature), "");
+        String message = req.getParameter("message");
+        if (message != null && message.isEmpty()) {
+            message = null;
+        }
+        if (message != null) {
+            if (message.startsWith(CONFIDENTIAL_MARKER)) {
+                message = " " + message;
+            }
+            String confidential = req.getParameter("confidential");
+            if (confidential != null && !confidential.isEmpty()) {
+                message = CONFIDENTIAL_MARKER + "\r\n" + message;
+            }
+            if (message.contains("---")) {
+                throw new GigiApiException("Your message may not contain '---'.");
+            }
+            // convert all line endings to CRLF
+            message = message.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "\r\n");
+            if ( !message.matches("[ -~\r\n\t]*")) {
+                throw new GigiApiException("Your message may only contain printable ASCII characters, tab, newline and space.");
+            }
+        }
+        CertificateOwner co = c.getOwner();
+        String primaryEmail;
+        Language l = Language.getInstance(Locale.ENGLISH);
+        if (co instanceof User) {
+            primaryEmail = ((User) co).getEmail();
+            l = Language.getInstance(((User) co).getPreferredLocale());
+        } else if (co instanceof Organisation) {
+            primaryEmail = ((Organisation) co).getContactEmail();
+        } else {
+            throw new IllegalArgumentException("certificate owner of unknown type");
+        }
+        HashMap<String, Object> vars = new HashMap<>();
+        vars.put("appName", ServerConstants.getAppName());
+        if (message != null && !message.startsWith(CONFIDENTIAL_MARKER)) {
+            vars.put("message", message);
+        } else {
+            vars.put("message", null);
+        }
+        vars.put("serial", c.getSerial());
+        try {
+            revocationNotice.sendMail(l, vars, primaryEmail);
+        } catch (IOException e) {
+            throw new GigiApiException("Sending the notification mail failed.");
+        }
+        Job j = c.revoke(challenge, Base64.getEncoder().encodeToString(signature), message);
         if ( !j.waitFor(60000)) {
             throw new PermamentFormException(new GigiApiException("Revocation timed out."));
         }
index e75d271..9aadd3a 100644 (file)
@@ -38,6 +38,17 @@ printf '%s' '<?=$challengePrefix?><?=$challenge?>' | openssl dgst -sha256 -sign
       <input type="text" class="form-control" name="signature" placeholder="<?=_Signature?>">
     </td>
   </tr>
+  <tr>
+    <td colspan="4">
+      <?=_You may provide information on how the private key was compromised to help the certificate owner prevent further key compromises.?>
+      <?=_You can indicate that this information should not be sent to the certificate owner, but only be visible to ${appName} staff, by checking the checkbox below.?>
+      <p>
+      <label for="confidential"><?=_Don't send the message to the certificate owner?></label>
+      <input type='checkbox' name='confidential' id='confidential'>
+      </p>
+      <textarea class="form-control" name="message" rows="3" cols="40"></textarea>
+    </td>
+  </tr>
   <tr>
     <td colspan="4"><input class="btn btn-primary" type="submit" name="process" value="<?=_Next?>"></td>
   </tr>
index c36b176..695f725 100644 (file)
@@ -1,7 +1,6 @@
 package club.wpia.gigi.pages.main;
 
 import java.io.IOException;
-import java.util.HashMap;
 
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
@@ -24,7 +23,7 @@ public class KeyCompromisePage extends ManagedFormPage {
 
     @Override
     public void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
-        new KeyCompromiseForm(req).output(resp.getWriter(), getLanguage(req), new HashMap<String, Object>());
+        new KeyCompromiseForm(req).output(resp.getWriter(), getLanguage(req), getDefaultVars(req));
     }
 
 }
diff --git a/src/club/wpia/gigi/pages/main/RevocationNotice.templ b/src/club/wpia/gigi/pages/main/RevocationNotice.templ
new file mode 100644 (file)
index 0000000..453ceea
--- /dev/null
@@ -0,0 +1,17 @@
+Subject: <?=_Certification Revocation Notice due to Key Compromise?>
+
+<?=_The private key for your certificate ${serial} was reported to be compromised. The certificate has therefore been revoked.?>
+
+<?=_The reporting user was able to sign a system-generated challenge and the signature verifies successfully with your public key.?>
+<?=_This means that they somehow have the ability to sign with your private key. This way your certificate could be used by someone else to impersonate the identity represented by the certificate. This might have several causes:?>
+
+- <?=_Your private key became publicly accessible.?>
+- <?=_Your key pair was so weak that it could be broken by another individual.?>
+- <?=_Someone with authorized access to your private key (e.g. you) has initiated the revocation.?>
+
+<? if($message) { ?>
+The user reporting this compromised key provided the following message to aid you to resolve this security issue and help you to prevent further security incidents.
+---
+<?=$message?>
+---
+<? } ?>
\ No newline at end of file
index 1074a51..af9cbb6 100644 (file)
@@ -36,6 +36,8 @@ import club.wpia.gigi.util.PEM;
 @RunWith(Parameterized.class)
 public class KeyCompromiseTest extends ClientTest {
 
+    private static final String NOT_FOUND = "not found";
+
     private static class TestParameters {
 
         private final String query;
@@ -48,6 +50,9 @@ public class KeyCompromiseTest extends ClientTest {
         }
 
         public String getError() {
+            if (NOT_FOUND.equals(error)) {
+                return KeyCompromiseForm.NOT_FOUND.getRaw();
+            }
             return error;
         }
 
@@ -91,13 +96,13 @@ public class KeyCompromiseTest extends ClientTest {
                 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()),
+                params("serial=0000&priv=%priv", NOT_FOUND),
+                params("serial=0lkd&priv=%priv", NOT_FOUND),
                 // 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()),
+                params("serial=1&priv=%priv", NOT_FOUND),
+                params("serial=1%serial&priv=%priv", NOT_FOUND),
                 // missing certificate identification
                 params("serial=&cert=&priv=%priv", "identification"),
                 params("cert=&priv=%priv", "identification"),
diff --git a/tests/club/wpia/gigi/pages/main/KeyCompromiseTestMessage.java b/tests/club/wpia/gigi/pages/main/KeyCompromiseTestMessage.java
new file mode 100644 (file)
index 0000000..06a5ba5
--- /dev/null
@@ -0,0 +1,126 @@
+package club.wpia.gigi.pages.main;
+
+import static org.junit.Assert.*;
+
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.net.HttpURLConnection;
+import java.net.URLEncoder;
+import java.security.GeneralSecurityException;
+import java.security.KeyPair;
+import java.security.PrivateKey;
+import java.security.cert.CertificateEncodingException;
+
+import org.hamcrest.CoreMatchers;
+import org.junit.Test;
+
+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.testUtils.TestEmailReceiver.TestMail;
+import club.wpia.gigi.util.AuthorizationContext;
+import club.wpia.gigi.util.HTMLEncoder;
+import club.wpia.gigi.util.PEM;
+
+public class KeyCompromiseTestMessage extends ClientTest {
+
+    private Certificate cert;
+
+    private PrivateKey priv;
+
+    public KeyCompromiseTestMessage() throws GeneralSecurityException, IOException, GigiApiException, InterruptedException {
+        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);
+    }
+
+    @Test
+    public void testExecution() throws IOException, InterruptedException, GigiApiException, GeneralSecurityException {
+        reportCompromiseAndCheck("");
+    }
+
+    @Test
+    public void testNoConfidential() throws IOException, InterruptedException, GigiApiException, GeneralSecurityException {
+        TestMail rc = reportCompromiseAndCheck("message=test+message");
+        assertThat(rc.getMessage(), CoreMatchers.containsString("test message"));
+    }
+
+    @Test
+    public void testNoConfidentialButMarker() throws IOException, InterruptedException, GigiApiException, GeneralSecurityException {
+        TestMail rc = reportCompromiseAndCheck("message=" + URLEncoder.encode(KeyCompromiseForm.CONFIDENTIAL_MARKER + "\ntest message", "UTF-8"));
+        assertThat(rc.getMessage(), CoreMatchers.containsString("test message"));
+        assertThat(rc.getMessage(), CoreMatchers.containsString(" " + KeyCompromiseForm.CONFIDENTIAL_MARKER));
+    }
+
+    @Test
+    public void testConfidential() throws IOException, InterruptedException, GigiApiException, GeneralSecurityException {
+        TestMail rc = reportCompromiseAndCheck("message=test+message&confidential=on");
+        assertThat(rc.getMessage(), CoreMatchers.not(CoreMatchers.containsString("test message")));
+    }
+
+    @Test
+    public void testCR() throws IOException, InterruptedException, GigiApiException, GeneralSecurityException {
+        TestMail rc = reportCompromiseAndCheck("message=test%0Dmessage&confidential=on");
+        assertThat(rc.getMessage(), CoreMatchers.not(CoreMatchers.containsString("test\r\nmessage")));
+    }
+
+    @Test
+    public void testLF() throws IOException, InterruptedException, GigiApiException, GeneralSecurityException {
+        TestMail rc = reportCompromiseAndCheck("message=test%0Amessage&confidential=on");
+        assertThat(rc.getMessage(), CoreMatchers.not(CoreMatchers.containsString("test\r\nmessage")));
+    }
+
+    @Test
+    public void testCRLF() throws IOException, InterruptedException, GigiApiException, GeneralSecurityException {
+        TestMail rc = reportCompromiseAndCheck("message=test%0D%0Amessage&confidential=on");
+        assertThat(rc.getMessage(), CoreMatchers.not(CoreMatchers.containsString("test\r\nmessage")));
+    }
+
+    @Test
+    public void testIllegalContent() throws IOException, InterruptedException, GigiApiException, GeneralSecurityException {
+        HttpURLConnection rc = reportCompromise("message=test+message+---&confidential=on");
+        String data = IOUtils.readURL(rc);
+        assertThat(data, hasError());
+        assertThat(data, CoreMatchers.containsString(HTMLEncoder.encodeHTML("message may not contain '---'")));
+        assertNull(getMailReceiver().poll());
+        assertEquals(CertificateStatus.ISSUED, cert.getStatus());
+    }
+
+    @Test
+    public void testIllegalChars() throws IOException, InterruptedException, GigiApiException, GeneralSecurityException {
+        HttpURLConnection rc = reportCompromise("message=" + URLEncoder.encode("§", "UTF-8"));
+        String data = IOUtils.readURL(rc);
+        assertThat(data, hasError());
+        assertThat(data, CoreMatchers.containsString("may only contain printable ASCII characters"));
+        assertEquals(CertificateStatus.ISSUED, cert.getStatus());
+    }
+
+    private TestMail reportCompromiseAndCheck(String params) throws IOException, UnsupportedEncodingException, CertificateEncodingException, GeneralSecurityException {
+        HttpURLConnection huc = reportCompromise(params);
+        assertThat(IOUtils.readURL(huc), hasNoError());
+        TestMail rc = getMailReceiver().receive();
+        assertEquals(u.getEmail(), rc.getTo());
+        assertThat(rc.getMessage(), CoreMatchers.containsString(cert.getSerial()));
+        assertEquals(CertificateStatus.REVOKED, cert.getStatus());
+        return rc;
+    }
+
+    private HttpURLConnection reportCompromise(String params) throws IOException, UnsupportedEncodingException, CertificateEncodingException, GeneralSecurityException {
+        if ( !params.isEmpty() && !params.startsWith("&")) {
+            params = "&" + params;
+        }
+        HttpURLConnection huc = post(KeyCompromisePage.PATH, "cert=" + URLEncoder.encode(PEM.encode("CERTIFICATE", cert.cert().getEncoded()), "UTF-8")//
+                + "&priv=" + URLEncoder.encode(PEM.encode("PRIVATE KEY", priv.getEncoded()), "UTF-8") + params);
+        return huc;
+    }
+}