Repository: certbot/certbot

Issue:
Allow non-interactive revocation without deleting certificates
## My operating system is (include version):
Xubuntu 17.10

## I installed Certbot with (certbot-auto, OS package manager, pip, etc):
git + certbot-auto

## I ran this command and it produced this output:
certbot-auto revoke -n --cert-path /path --key-path /path --reason cessationofoperation

## Certbot's behavior differed from what I expected because:
There is no option to operate non-interactively AND not delete the certificates. I was able to achieve this prior to https://github.com/certbot/issues/4914 and just got around to updating my scripting to discover this appears to no longer be possible.

This is a request for a flag to NOT delete certs when revoking non-interactively. The reason I need this is so the certificates can still be used to check OCSP and verify that they were successfully revoked, all via non-interactive automation.


Discussion:
Hmm yeah this seems like an issue. I'd be happy to possibly add a --no-delete flag or something of the sort to allow this. I'm curious what @bmw thinks of this use case.
I'm happy to add this to my todo list for when I get around to #4258
Alternately if you wanted to submit a PR, we'd happily review it, though I might wait to hear what @bmw thinks about implementation methods.
We definitely want to keep the default behavior as "cert that is revoked does not get renewed on `certbot renew`". Whether we do the simple thing and add a flag here, or rework `certbot revoke` to stick a flag in the renewal conf file, or any other option, we should keep that behavior.
Good point about avoiding renewal.

While we could potentially flag the cert so it's not renewed with `certbot renew` in the future if we had a good reason to, for now I personally I think we should take the approach we've used a lot for yes or no questions like this. We add two flags named something like `--delete-after-revoke` and `--no-delete-after-revoke`. This allows people to automate their way past this question with either answer in both interactive and non-interactive mode. The help for these flags should go under the revoke subcommand.

Unless anyone has any objections, @danjjeff, if you're interested in taking a crack at this, let us know. Alternatively, @SwartzCr, if you think you can get to it by the next release, can you assign this to yourself, otherwise, can you assign it to me?
I'll see if I can over the holidays when I will hopefully have a few space cycles.

Resolving patch:
diff --git a/certbot/cli.py b/certbot/cli.py
--- a/certbot/cli.py
+++ b/certbot/cli.py
@@ -1220,6 +1220,18 @@ def _create_subparsers(helpful):
                                                    key=constants.REVOCATION_REASONS.get)),
                 action=_EncodeReasonAction, default=flag_default("reason"),
                 help="Specify reason for revoking certificate. (default: unspecified)")
+    helpful.add("revoke",
+                "--delete-after-revoke", action="store_true",
+                default=flag_default("delete_after_revoke"),
+                help="Delete certificates after revoking them.")
+    helpful.add("revoke",
+                "--no-delete-after-revoke", action="store_false",
+                dest="delete_after_revoke",
+                default=flag_default("delete_after_revoke"),
+                help="Do not delete certificates after revoking them. This "
+                     "option should be used with caution because the 'renew' "
+                     "subcommand will attempt to renew undeleted revoked "
+                     "certificates.")
     helpful.add("rollback",
                 "--checkpoints", type=int, metavar="N",
                 default=flag_default("rollback_checkpoints"),
diff --git a/certbot/constants.py b/certbot/constants.py
--- a/certbot/constants.py
+++ b/certbot/constants.py
@@ -71,6 +71,7 @@
     user_agent_comment=None,
     csr=None,
     reason=0,
+    delete_after_revoke=None,
     rollback_checkpoints=1,
     init=False,
     prepare=False,
diff --git a/certbot/main.py b/certbot/main.py
--- a/certbot/main.py
+++ b/certbot/main.py
@@ -536,9 +536,11 @@ def _delete_if_appropriate(config): # pylint: disable=too-many-locals,too-many-b
     display = zope.component.getUtility(interfaces.IDisplay)
     reporter_util = zope.component.getUtility(interfaces.IReporter)
 
-    msg = ("Would you like to delete the cert(s) you just revoked?")
-    attempt_deletion = display.yesno(msg, yes_label="Yes (recommended)", no_label="No",
-            force_interactive=True, default=True)
+    attempt_deletion = config.delete_after_revoke
+    if attempt_deletion is None:
+        msg = ("Would you like to delete the cert(s) you just revoked?")
+        attempt_deletion = display.yesno(msg, yes_label="Yes (recommended)", no_label="No",
+                force_interactive=True, default=True)
 
     if not attempt_deletion:
         reporter_util.add_message("Not deleting revoked certs.", reporter_util.LOW_PRIORITY)


Test patch:
diff --git a/certbot/tests/cli_test.py b/certbot/tests/cli_test.py
--- a/certbot/tests/cli_test.py
+++ b/certbot/tests/cli_test.py
@@ -164,6 +164,8 @@ def test_help(self):
         self.assertTrue("--cert-path" in out)
         self.assertTrue("--key-path" in out)
         self.assertTrue("--reason" in out)
+        self.assertTrue("--delete-after-revoke" in out)
+        self.assertTrue("--no-delete-after-revoke" in out)
 
         out = self._help_output(['-h', 'config_changes'])
         self.assertTrue("--cert-path" not in out)
@@ -412,6 +414,18 @@ def test_no_directory_hooks_set(self):
     def test_no_directory_hooks_unset(self):
         self.assertTrue(self.parse([]).directory_hooks)
 
+    def test_delete_after_revoke(self):
+        namespace = self.parse(["--delete-after-revoke"])
+        self.assertTrue(namespace.delete_after_revoke)
+
+    def test_delete_after_revoke_default(self):
+        namespace = self.parse([])
+        self.assertEqual(namespace.delete_after_revoke, None)
+
+    def test_no_delete_after_revoke(self):
+        namespace = self.parse(["--no-delete-after-revoke"])
+        self.assertFalse(namespace.delete_after_revoke)
+
 
 class DefaultTest(unittest.TestCase):
     """Tests for certbot.cli._Default."""
diff --git a/certbot/tests/main_test.py b/certbot/tests/main_test.py
--- a/certbot/tests/main_test.py
+++ b/certbot/tests/main_test.py
@@ -298,25 +298,29 @@ def test_revocation_with_prompt(self, mock_get_utility,
         self._call()
         self.assertFalse(mock_delete.called)
 
-class DeleteIfAppropriateTest(unittest.TestCase):
+class DeleteIfAppropriateTest(test_util.ConfigTestCase):
     """Tests for certbot.main._delete_if_appropriate """
 
-    def setUp(self):
-        self.config = mock.Mock()
-        self.config.namespace = mock.Mock()
-        self.config.namespace.noninteractive_mode = False
-
     def _call(self, mock_config):
         from certbot.main import _delete_if_appropriate
         _delete_if_appropriate(mock_config)
 
-    @mock.patch('certbot.cert_manager.delete')
+    def _test_delete_opt_out_common(self, mock_get_utility):
+        with mock.patch('certbot.cert_manager.delete') as mock_delete:
+            self._call(self.config)
+        mock_delete.assert_not_called()
+        self.assertTrue(mock_get_utility().add_message.called)
+
+    @test_util.patch_get_utility()
+    def test_delete_flag_opt_out(self, mock_get_utility):
+        self.config.delete_after_revoke = False
+        self._test_delete_opt_out_common(mock_get_utility)
+
     @test_util.patch_get_utility()
-    def test_delete_opt_out(self, mock_get_utility, mock_delete):
+    def test_delete_prompt_opt_out(self, mock_get_utility):
         util_mock = mock_get_utility()
         util_mock.yesno.return_value = False
-        self._call(self.config)
-        mock_delete.assert_not_called()
+        self._test_delete_opt_out_common(mock_get_utility)
 
     # pylint: disable=too-many-arguments
     @mock.patch('certbot.storage.renewal_file_for_certname')
@@ -397,6 +401,28 @@ def test_noninteractive_deletion(self, mock_get_utility, mock_delete,
         self._call(config)
         self.assertEqual(mock_delete.call_count, 1)
 
+    # pylint: disable=too-many-arguments
+    @mock.patch('certbot.storage.renewal_file_for_certname')
+    @mock.patch('certbot.cert_manager.match_and_check_overlaps')
+    @mock.patch('certbot.storage.full_archive_path')
+    @mock.patch('certbot.cert_manager.cert_path_to_lineage')
+    @mock.patch('certbot.cert_manager.delete')
+    @test_util.patch_get_utility()
+    def test_opt_in_deletion(self, mock_get_utility, mock_delete,
+            mock_cert_path_to_lineage, mock_full_archive_dir,
+            mock_match_and_check_overlaps, mock_renewal_file_for_certname):
+        # pylint: disable = unused-argument
+        config = self.config
+        config.namespace.delete_after_revoke = True
+        config.cert_path = "/some/reasonable/path"
+        config.certname = ""
+        mock_cert_path_to_lineage.return_value = "example.com"
+        mock_full_archive_dir.return_value = ""
+        mock_match_and_check_overlaps.return_value = ""
+        self._call(config)
+        self.assertEqual(mock_delete.call_count, 1)
+        self.assertFalse(mock_get_utility().yesno.called)
+
     # pylint: disable=too-many-arguments
     @mock.patch('certbot.storage.renewal_file_for_certname')
     @mock.patch('certbot.cert_manager.match_and_check_overlaps')
diff --git a/tests/boulder-integration.sh b/tests/boulder-integration.sh
--- a/tests/boulder-integration.sh
+++ b/tests/boulder-integration.sh
@@ -345,9 +345,14 @@ common auth --must-staple --domains "must-staple.le.wtf"
 openssl x509 -in "${root}/conf/live/must-staple.le.wtf/cert.pem" -text | grep '1.3.6.1.5.5.7.1.24'
 
 # revoke by account key
-common revoke --cert-path "$root/conf/live/le.wtf/cert.pem"
+common revoke --cert-path "$root/conf/live/le.wtf/cert.pem" --delete-after-revoke
 # revoke renewed
-common revoke --cert-path "$root/conf/live/le1.wtf/cert.pem"
+common revoke --cert-path "$root/conf/live/le1.wtf/cert.pem" --no-delete-after-revoke
+if [ ! -d "$root/conf/live/le1.wtf" ]; then
+    echo "cert deleted when --no-delete-after-revoke was used!"
+    exit 1
+fi
+common delete --cert-name le1.wtf
 # revoke by cert key
 common revoke --cert-path "$root/conf/live/le2.wtf/cert.pem" \
     --key-path "$root/conf/live/le2.wtf/privkey.pem"