Repository: certbot/certbot

Issue:
Is there an alternative way of proofing a webserver's property?
Hey guys,

I wanted to try out the lets-encrypt-preview, but it seems to currently support only apache and nginx.

Is there a way to manually configure the proofs etc.?

Like "you have to put this proof file at /blabla12836.html on example.org. Then execute this wizard again."

For my infrastructure, I'm using mostly nodejs / iojs static builds. That's why my paths of servers and apps are completely different compared to usual apache paths.

I would also love to add support for nodejs to this project, but I'm kind of stuck in between the whole process. Are there any documentation on how to create your own "platform adapter"?

Also, is there a plan to help this tool support authentication of onion domains within the TOR project or say, hyperboria? I think this project has huge potential bringing meshnets to a whole new level.



Discussion:

Resolving patch:
diff --git a/acme/challenges.py b/acme/challenges.py
--- a/acme/challenges.py
+++ b/acme/challenges.py
@@ -46,7 +46,6 @@ class SimpleHTTP(DVChallenge):
     """ACME "simpleHttp" challenge."""
     typ = "simpleHttp"
     token = jose.Field("token")
-    tls = jose.Field("tls", default=True, omitempty=True)
 
 
 @ChallengeResponse.register
@@ -54,20 +53,43 @@ class SimpleHTTPResponse(ChallengeResponse):
     """ACME "simpleHttp" challenge response."""
     typ = "simpleHttp"
     path = jose.Field("path")
+    tls = jose.Field("tls", default=True, omitempty=True)
+
+    URI_ROOT_PATH = ".well-known/acme-challenge"
+    """URI root path for the server provisioned resource."""
 
-    URI_TEMPLATE = "https://{domain}/.well-known/acme-challenge/{path}"
-    """URI template for HTTPS server provisioned resource."""
+    _URI_TEMPLATE = "{scheme}://{domain}/" + URI_ROOT_PATH + "/{path}"
+
+    MAX_PATH_LEN = 25
+    """Maximum allowed `path` length."""
+
+    @property
+    def good_path(self):
+        """Is `path` good?
+
+        .. todo:: acme-spec: "The value MUST be comprised entirely of
+           haracters from the URL-safe alphabet for Base64 encoding
+           [RFC4648]", base64.b64decode ignores those characters
+
+        """
+        return len(self.path) <= 25
+
+    @property
+    def scheme(self):
+        """URL scheme for the provisioned resource."""
+        return "https" if self.tls else "http"
 
     def uri(self, domain):
         """Create an URI to the provisioned resource.
 
-        Forms an URI to the HTTPS server provisioned resource (containing
-        :attr:`~SimpleHTTP.token`) by populating the :attr:`URI_TEMPLATE`.
+        Forms an URI to the HTTPS server provisioned resource
+        (containing :attr:`~SimpleHTTP.token`).
 
         :param str domain: Domain name being verified.
 
         """
-        return self.URI_TEMPLATE.format(domain=domain, path=self.path)
+        return self._URI_TEMPLATE.format(
+            scheme=self.scheme, domain=domain, path=self.path)
 
 
 @Challenge.register
diff --git a/letsencrypt/plugins/manual.py b/letsencrypt/plugins/manual.py
new file mode 100644
--- /dev/null
+++ b/letsencrypt/plugins/manual.py
@@ -0,0 +1,138 @@
+"""Manual plugin."""
+import logging
+import os
+import sys
+
+import requests
+import zope.component
+import zope.interface
+
+from acme import challenges
+from acme import jose
+
+from letsencrypt import interfaces
+from letsencrypt.plugins import common
+
+
+class ManualAuthenticator(common.Plugin):
+    """Manual Authenticator.
+
+    .. todo:: Support for `~.challenges.DVSNI`.
+
+    """
+    zope.interface.implements(interfaces.IAuthenticator)
+    zope.interface.classProvides(interfaces.IPluginFactory)
+
+    description = "Manual Authenticator"
+
+    MESSAGE_TEMPLATE = """\
+Make sure your web server displays the following content at
+{uri} before continuing:
+
+{achall.token}
+
+If you don't have HTTP server configured, you can run the following
+command on the target server (as root):
+
+{command}
+"""
+
+    HTTP_TEMPLATE = """\
+mkdir -p {response.URI_ROOT_PATH}
+echo -n {achall.token} > {response.URI_ROOT_PATH}/{response.path}
+# run only once per server:
+python -m SimpleHTTPServer 80"""
+    """Non-TLS command template."""
+
+    # https://www.piware.de/2011/01/creating-an-https-server-in-python/
+    HTTPS_TEMPLATE = """\
+mkdir -p {response.URI_ROOT_PATH}  # run only once per server
+echo -n {achall.token} > {response.URI_ROOT_PATH}/{response.path}
+# run only once per server:
+openssl req -new -newkey rsa:4096 -subj "/" -days 1 -nodes -x509 -keyout key.pem -out cert.pem
+python -c "import BaseHTTPServer, SimpleHTTPServer, ssl; \\
+s = BaseHTTPServer.HTTPServer(('', 443), SimpleHTTPServer.SimpleHTTPRequestHandler); \\
+s.socket = ssl.wrap_socket(s.socket, keyfile='key.pem', certfile='cert.pem'); \\
+s.serve_forever()" """
+    """TLS command template.
+
+    According to the ACME specification, "the ACME server MUST ignore
+    the certificate provided by the HTTPS server", so the first command
+    generates temporary self-signed certificate. For the same reason
+    ``requests.get`` in `_verify` sets ``verify=False``. Python HTTPS
+    server command serves the ``token`` on all URIs.
+
+    """
+
+    def __init__(self, *args, **kwargs):
+        super(ManualAuthenticator, self).__init__(*args, **kwargs)
+        self.template = (self.HTTP_TEMPLATE if self.config.no_simple_http_tls
+                         else self.HTTPS_TEMPLATE)
+
+    def prepare(self):  # pylint: disable=missing-docstring,no-self-use
+        pass  # pragma: no cover
+
+    def more_info(self):  # pylint: disable=missing-docstring,no-self-use
+        return """\
+This plugin requires user's manual intervention in setting up a HTTP
+server for solving SimpleHTTP challenges and thus does not need to be
+run as a privilidged process. Alternatively shows instructions on how
+to use Python's built-in HTTP server and, in case of HTTPS, openssl
+binary for temporary key/certificate generation.""".replace("\n", "")
+
+    def get_chall_pref(self, domain):
+        # pylint: disable=missing-docstring,no-self-use,unused-argument
+        return [challenges.SimpleHTTP]
+
+    def perform(self, achalls):  # pylint: disable=missing-docstring
+        responses = []
+        # TODO: group achalls by the same socket.gethostbyname(_ex)
+        # and prompt only once per server (one "echo -n" per domain)
+        for achall in achalls:
+            responses.append(self._perform_single(achall))
+        return responses
+
+    def _perform_single(self, achall):
+        # same path for each challenge response would be easier for
+        # users, but will not work if multiple domains point at the
+        # same server: default command doesn't support virtual hosts
+        response = challenges.SimpleHTTPResponse(
+            path=jose.b64encode(os.urandom(18)),
+            tls=(not self.config.no_simple_http_tls))
+        assert response.good_path  # is encoded os.urandom(18) good?
+
+        self._notify_and_wait(self.MESSAGE_TEMPLATE.format(
+            achall=achall, response=response,
+            uri=response.uri(achall.domain),
+            command=self.template.format(achall=achall, response=response)))
+
+        if self._verify(achall, response):
+            return response
+        else:
+            return None
+
+    def _notify_and_wait(self, message):  # pylint: disable=no-self-use
+        # TODO: IDisplay wraps messages, breaking the command
+        #answer = zope.component.getUtility(interfaces.IDisplay).notification(
+        #    message=message, height=25, pause=True)
+        sys.stdout.write(message)
+        raw_input("Press ENTER to continue")
+
+    def _verify(self, achall, chall_response):  # pylint: disable=no-self-use
+        uri = chall_response.uri(achall.domain)
+        logging.debug("Verifying %s...", uri)
+        try:
+            response = requests.get(uri, verify=False)
+        except requests.exceptions.ConnectionError as error:
+            logging.exception(error)
+            return False
+
+        ret = response.text == achall.token
+        if not ret:
+            logging.error("Unable to verify %s! Expected: %r, returned: %r.",
+                          uri, achall.token, response.text)
+
+        return ret
+
+    def cleanup(self, achalls):  # pylint: disable=missing-docstring,no-self-use
+        pass  # pragma: no cover
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -120,6 +120,7 @@ def read_file(filename, encoding='utf8'):
             'jws = letsencrypt.acme.jose.jws:CLI.run',
         ],
         'letsencrypt.plugins': [
+            'manual = letsencrypt.plugins.manual:ManualAuthenticator',
             'standalone = letsencrypt.plugins.standalone.authenticator'
             ':StandaloneAuthenticator',
 


Test patch:
diff --git a/acme/challenges_test.py b/acme/challenges_test.py
--- a/acme/challenges_test.py
+++ b/acme/challenges_test.py
@@ -27,17 +27,8 @@ def setUp(self):
         self.jmsg = {
             'type': 'simpleHttp',
             'token': 'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA',
-            'tls': True,
         }
 
-    def test_no_tls(self):
-        from acme.challenges import SimpleHTTP
-        self.assertEqual(SimpleHTTP(token='tok', tls=False).to_json(), {
-            'tls': False,
-            'token': 'tok',
-            'type': 'simpleHttp',
-        })
-
     def test_to_partial_json(self):
         self.assertEqual(self.jmsg, self.msg.to_partial_json())
 
@@ -54,27 +45,51 @@ class SimpleHTTPResponseTest(unittest.TestCase):
 
     def setUp(self):
         from acme.challenges import SimpleHTTPResponse
-        self.msg = SimpleHTTPResponse(path='6tbIMBC5Anhl5bOlWT5ZFA')
-        self.jmsg = {
+        self.msg_http = SimpleHTTPResponse(
+            path='6tbIMBC5Anhl5bOlWT5ZFA', tls=False)
+        self.msg_https = SimpleHTTPResponse(path='6tbIMBC5Anhl5bOlWT5ZFA')
+        self.jmsg_http = {
             'type': 'simpleHttp',
             'path': '6tbIMBC5Anhl5bOlWT5ZFA',
+            'tls': False,
         }
+        self.jmsg_https = {
+            'type': 'simpleHttp',
+            'path': '6tbIMBC5Anhl5bOlWT5ZFA',
+            'tls': True,
+        }
+
+    def test_good_path(self):
+        self.assertTrue(self.msg_http.good_path)
+        self.assertTrue(self.msg_https.good_path)
+        self.assertFalse(
+            self.msg_http.update(path=(self.msg_http.path * 10)).good_path)
+
+    def test_scheme(self):
+        self.assertEqual('http', self.msg_http.scheme)
+        self.assertEqual('https', self.msg_https.scheme)
 
     def test_uri(self):
+        self.assertEqual('http://example.com/.well-known/acme-challenge/'
+                         '6tbIMBC5Anhl5bOlWT5ZFA', self.msg_http.uri('example.com'))
         self.assertEqual('https://example.com/.well-known/acme-challenge/'
-                         '6tbIMBC5Anhl5bOlWT5ZFA', self.msg.uri('example.com'))
+                         '6tbIMBC5Anhl5bOlWT5ZFA', self.msg_https.uri('example.com'))
 
     def test_to_partial_json(self):
-        self.assertEqual(self.jmsg, self.msg.to_partial_json())
+        self.assertEqual(self.jmsg_http, self.msg_http.to_partial_json())
+        self.assertEqual(self.jmsg_https, self.msg_https.to_partial_json())
 
     def test_from_json(self):
         from acme.challenges import SimpleHTTPResponse
         self.assertEqual(
-            self.msg, SimpleHTTPResponse.from_json(self.jmsg))
+            self.msg_http, SimpleHTTPResponse.from_json(self.jmsg_http))
+        self.assertEqual(
+            self.msg_https, SimpleHTTPResponse.from_json(self.jmsg_https))
 
     def test_from_json_hashable(self):
         from acme.challenges import SimpleHTTPResponse
-        hash(SimpleHTTPResponse.from_json(self.jmsg))
+        hash(SimpleHTTPResponse.from_json(self.jmsg_http))
+        hash(SimpleHTTPResponse.from_json(self.jmsg_https))
 
 
 class DVSNITest(unittest.TestCase):
diff --git a/letsencrypt/plugins/manual_test.py b/letsencrypt/plugins/manual_test.py
new file mode 100644
--- /dev/null
+++ b/letsencrypt/plugins/manual_test.py
@@ -0,0 +1,59 @@
+"""Tests for letsencrypt.plugins.manual."""
+import unittest
+
+import mock
+import requests
+
+from acme import challenges
+
+from letsencrypt import achallenges
+from letsencrypt.tests import acme_util
+
+
+class ManualAuthenticatorTest(unittest.TestCase):
+    """Tests for letsencrypt.plugins.manual.ManualAuthenticator."""
+
+    def setUp(self):
+        from letsencrypt.plugins.manual import ManualAuthenticator
+        self.config = mock.MagicMock(no_simple_http_tls=True)
+        self.auth = ManualAuthenticator(config=self.config, name="manual")
+        self.achalls = [achallenges.SimpleHTTP(
+            challb=acme_util.SIMPLE_HTTP, domain="foo.com", key=None)]
+
+    def test_more_info(self):
+        self.assertTrue(isinstance(self.auth.more_info(), str))
+
+    def test_get_chall_pref(self):
+        self.assertTrue(all(issubclass(pref, challenges.Challenge)
+                            for pref in self.auth.get_chall_pref("foo.com")))
+
+    def test_perform_empty(self):
+        self.assertEqual([], self.auth.perform([]))
+
+    @mock.patch("letsencrypt.plugins.manual.sys.stdout")
+    @mock.patch("letsencrypt.plugins.manual.os.urandom")
+    @mock.patch("letsencrypt.plugins.manual.requests.get")
+    @mock.patch("__builtin__.raw_input")
+    def test_perform(self, mock_raw_input, mock_get, mock_urandom, mock_stdout):
+        mock_urandom.return_value = "foo"
+        mock_get().text = self.achalls[0].token
+
+        self.assertEqual(
+            [challenges.SimpleHTTPResponse(tls=False, path='Zm9v')],
+            self.auth.perform(self.achalls))
+        mock_raw_input.assert_called_once()
+        mock_get.assert_called_with(
+            "http://foo.com/.well-known/acme-challenge/Zm9v", verify=False)
+
+        message = mock_stdout.write.mock_calls[0][1][0]
+        self.assertTrue(self.achalls[0].token in message)
+        self.assertTrue('Zm9v' in message)
+
+        mock_get().text = self.achalls[0].token + '!'
+        self.assertEqual([None], self.auth.perform(self.achalls))
+
+        mock_get.side_effect = requests.exceptions.ConnectionError
+        self.assertEqual([None], self.auth.perform(self.achalls))
+
+if __name__ == "__main__":
+    unittest.main()  # pragma: no cover
diff --git a/letsencrypt/tests/acme_util.py b/letsencrypt/tests/acme_util.py
--- a/letsencrypt/tests/acme_util.py
+++ b/letsencrypt/tests/acme_util.py
@@ -16,7 +16,7 @@
         "acme.jose", os.path.join("testdata", "rsa512_key.pem"))))
 
 # Challenges
-SIMPLE_HTTPS = challenges.SimpleHTTP(
+SIMPLE_HTTP = challenges.SimpleHTTP(
     token="evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA")
 DVSNI = challenges.DVSNI(
     r="O*\xb4-\xad\xec\x95>\xed\xa9\r0\x94\xe8\x97\x9c&6\xbf'\xb3"
@@ -47,7 +47,7 @@
     )
 )
 
-CHALLENGES = [SIMPLE_HTTPS, DVSNI, DNS, RECOVERY_CONTACT, RECOVERY_TOKEN, POP]
+CHALLENGES = [SIMPLE_HTTP, DVSNI, DNS, RECOVERY_CONTACT, RECOVERY_TOKEN, POP]
 DV_CHALLENGES = [chall for chall in CHALLENGES
                  if isinstance(chall, challenges.DVChallenge)]
 CONT_CHALLENGES = [chall for chall in CHALLENGES
@@ -86,13 +86,13 @@ def chall_to_challb(chall, status):  # pylint: disable=redefined-outer-name
 
 # Pending ChallengeBody objects
 DVSNI_P = chall_to_challb(DVSNI, messages2.STATUS_PENDING)
-SIMPLE_HTTPS_P = chall_to_challb(SIMPLE_HTTPS, messages2.STATUS_PENDING)
+SIMPLE_HTTP_P = chall_to_challb(SIMPLE_HTTP, messages2.STATUS_PENDING)
 DNS_P = chall_to_challb(DNS, messages2.STATUS_PENDING)
 RECOVERY_CONTACT_P = chall_to_challb(RECOVERY_CONTACT, messages2.STATUS_PENDING)
 RECOVERY_TOKEN_P = chall_to_challb(RECOVERY_TOKEN, messages2.STATUS_PENDING)
 POP_P = chall_to_challb(POP, messages2.STATUS_PENDING)
 
-CHALLENGES_P = [SIMPLE_HTTPS_P, DVSNI_P, DNS_P,
+CHALLENGES_P = [SIMPLE_HTTP_P, DVSNI_P, DNS_P,
                 RECOVERY_CONTACT_P, RECOVERY_TOKEN_P, POP_P]
 DV_CHALLENGES_P = [challb for challb in CHALLENGES_P
                    if isinstance(challb.chall, challenges.DVChallenge)]
diff --git a/letsencrypt/tests/auth_handler_test.py b/letsencrypt/tests/auth_handler_test.py
--- a/letsencrypt/tests/auth_handler_test.py
+++ b/letsencrypt/tests/auth_handler_test.py
@@ -300,7 +300,7 @@ def _call(cls, challbs, preferences, combinations):
 
     def test_common_case(self):
         """Given DVSNI and SimpleHTTP with appropriate combos."""
-        challbs = (acme_util.DVSNI_P, acme_util.SIMPLE_HTTPS_P)
+        challbs = (acme_util.DVSNI_P, acme_util.SIMPLE_HTTP_P)
         prefs = [challenges.DVSNI]
         combos = ((0,), (1,))
 
@@ -315,7 +315,7 @@ def test_common_case_with_continuity(self):
         challbs = (acme_util.RECOVERY_TOKEN_P,
                    acme_util.RECOVERY_CONTACT_P,
                    acme_util.DVSNI_P,
-                   acme_util.SIMPLE_HTTPS_P)
+                   acme_util.SIMPLE_HTTP_P)
         prefs = [challenges.RecoveryToken, challenges.DVSNI]
         combos = acme_util.gen_combos(challbs)
         self.assertEqual(self._call(challbs, prefs, combos), (0, 2))
@@ -328,7 +328,7 @@ def test_full_cont_server(self):
                    acme_util.RECOVERY_CONTACT_P,
                    acme_util.POP_P,
                    acme_util.DVSNI_P,
-                   acme_util.SIMPLE_HTTPS_P,
+                   acme_util.SIMPLE_HTTP_P,
                    acme_util.DNS_P)
         # Typical webserver client that can do everything except DNS
         # Attempted to make the order realistic
@@ -413,7 +413,7 @@ def test_empty_satisfied(self):
     def test_mutually_exclusvie(self):
         self.assertFalse(
             self._call(
-                acme_util.DVSNI_P, frozenset([acme_util.SIMPLE_HTTPS_P])))
+                acme_util.DVSNI_P, frozenset([acme_util.SIMPLE_HTTP_P])))
 
     def test_mutually_exclusive_same_type(self):
         self.assertTrue(