Commit bdc36eb8 authored by Adrien Dorsaz's avatar Adrien Dorsaz

Merge branch 'acmedirectory_rebase' into 'master'

Use of ACME Directory and automatic update agreement to terms of service

* Update main script to:
  * Use the ACME directory to retrieve correct URLs from the ACME servers
  * Be able to update account informations (contact information and agreed terms of service)
  * Read terms of service links from either the ACME directory or account informations
  * Automatically update terms of service agreement with latest changes (user have to stay informed about the updates himself)
  * Fix HTTP errors handling
* Update configuration to:
  *  Configure ACME directory instead of CAUrl
  *  Allow to specify contact informations (email and phone number ; beware that Let's Encrypt servers don't allow phones)
* Update tests to:
  * Correctly setup before running tests without using global variable
  * Correctly clean setup after running tests (correctly remove ACME account and close temporary files)
  * Update requirements to latest dnspython release (as release 1.15 has fixed the dns updates issue)
* Update account delete script (you can find it in /tests/) according to updates of the main script

See merge request !5
parents 4f2dfcb7 a604a6d5
Pipeline #94 passed with stage
in 1 minute and 36 seconds
......@@ -5,5 +5,5 @@ before_script:
coverage:
script:
- coverage run --source ./ -m unittest tests
- coverage run --source ./ -m unittest -v tests
- coverage report --include=acme_dns_tiny.py
......@@ -9,10 +9,15 @@ validation.
Since it has to have access to your private ACME account key and the
rights to update the DNS records of your DNS server, this code has been designed
to be as tiny as possible (currently less than 250 lines).
to be as tiny as possible (currently less than 300 lines).
The only prerequisites are Python 3, OpenSSL and the dnspython module (with
release before 1.14.0 (this release have a bug with dynamic DNS updates)).
The only prerequisites are Python 3, OpenSSL and the dnspython module.
For the dnspython module, be aware that it won't work with release 1.14.0,
because this one have a bug with dynamic DNS updates.
You should either use an older version from dnspython3 module (python3 specific
code) or any release of dnspython module (pyhton2 and python3 merged code) since
1.15.0.
**PLEASE READ THE SOURCE CODE! YOU MUST TRUST IT!
IT HANDLES YOUR ACCOUNT PRIVATE KEYS!**
......
This diff is collapsed.
......@@ -2,11 +2,15 @@
# Required readable ACME account key
AccountKeyFile = account.key
# Required readable CSR file
CSRFile = ../adtdev/adtdev.adorsaz.ch.csr
# Optional CA url (default: https://acme-staging.api.letsencrypt.org)
CAUrl = https://acme-staging.api.letsencrypt.org
CSRFile = domain.csr
# Optional ACME directory url (default: https://acme-staging.api.letsencrypt.org/directory)
ACMEDirectory = https://acme-staging.api.letsencrypt.org/directory
# Optional time in seconds to wait between DNS update and challenge check (default: 3)
CheckChallengeDelay = 3
# Optional Contact info to send to the ACME provider
MailContact = mail@example.com
# Note that Let's Encrypt servers disallow use of phone numbers
PhoneContact = +11111111111
[TSIGKeyring]
# Required TSIG key name
......@@ -14,7 +18,7 @@ KeyName = host-example
# Required TSIG key value in base64
KeyValue = XXXXXXXXXXX==
# Required TSIG algorithm
Algorithm = hmac-sha256
Algorithm = hmac-sha256
[DNS]
# Required name of zone to update
......
import subprocess, os, json, base64, binascii, re, copy, logging
from urllib.request import urlopen
from urllib.error import HTTPError
CAURL = os.getenv("GITLABCI_CAURL", "https://acme-staging.api.letsencrypt.org")
ACMEDirectory = os.getenv("GITLABCI_ACMEDIRECTORY", "https://acme-staging.api.letsencrypt.org/directory")
LOGGER = logging.getLogger(__name__)
LOGGER.addHandler(logging.StreamHandler())
......@@ -9,9 +10,9 @@ LOGGER.setLevel(logging.INFO)
def delete_account(accountkeypath, log=LOGGER):
# helper function base64 encode for jose spec
# helper function base64 encode as defined in acme spec
def _b64(b):
return base64.urlsafe_b64encode(b).decode("utf8").replace("=", "")
return base64.urlsafe_b64encode(b).decode("utf8").rstrip("=")
# helper function to run openssl command
def _openssl(command, options, communicate=None):
......@@ -26,7 +27,7 @@ def delete_account(accountkeypath, log=LOGGER):
def _send_signed_request(url, payload):
payload64 = _b64(json.dumps(payload).encode("utf8"))
protected = copy.deepcopy(header)
protected["nonce"] = urlopen(CAURL + "/directory").headers["Replay-Nonce"]
protected["nonce"] = urlopen(ACMEDirectory).headers["Replay-Nonce"]
protected64 = _b64(json.dumps(protected).encode("utf8"))
signature = _openssl("dgst", ["-sha256", "-sign", accountkeypath],
"{0}.{1}".format(protected64, payload64).encode("utf8"))
......@@ -36,9 +37,9 @@ def delete_account(accountkeypath, log=LOGGER):
})
try:
resp = urlopen(url, data.encode("utf8"))
return resp.getcode(), resp.read()
except IOError as e:
return getattr(e, "code", None), getattr(e, "read", e.__str__)()
return resp.getcode(), resp.read(), resp.getheaders()
except HTTPError as httperror:
return httperror.getcode(), httperror.read(), httperror.getheaders()
# parse account key to get public key
log.info("Parsing account key...")
......@@ -57,12 +58,28 @@ def delete_account(accountkeypath, log=LOGGER):
},
}
# send request to delete account key
# get ACME server configuration from the directory
directory = urlopen(ACMEDirectory)
acme_config = json.loads(directory.read().decode("utf8"))
log.info("Register account to get account URL.")
code, result, headers = _send_signed_request(acme_config["new-reg"], {
"resource": "new-reg"
})
if code == 201:
account_url = dict(headers).get("Location")
log.info("Registered! (account: '{0}')".format(account_url))
elif code == 409:
account_url = dict(headers).get("Location")
log.info("Already registered! (account: '{0}')".format(account_url))
log.info("Delete account...")
code, result = _send_signed_request(CAURL + "/acme/new-reg", {
code, result, headers = _send_signed_request(account_url, {
"resource": "reg",
"delete": True,
})
if code != 200:
if code not in [200,202]:
raise ValueError("Error deleting account key: {0} {1}".format(code, result))
log.info("Account key deleted !")
......@@ -4,7 +4,7 @@ from subprocess import Popen
# domain with server.py running on it for testing
DOMAIN = os.getenv("GITLABCI_DOMAIN")
CAURL = os.getenv("GITLABCI_CAURL", "https://acme-staging.api.letsencrypt.org")
ACMEDIRECTORY = os.getenv("GITLABCI_ACMEDIRECTORY", "https://acme-staging.api.letsencrypt.org/directory")
CHALLENGEDELAY = os.getenv("GITLABCI_CHALLENGEDELAY", "3")
DNSHOST = os.getenv("GITLABCI_DNSHOST")
DNSHOSTIP = os.getenv("GITLABCI_DNSHOSTIP")
......@@ -48,8 +48,10 @@ def gen_config():
# Default test configuration
config = configparser.ConfigParser()
config.read("./example.ini".format(DOMAIN))
config["acmednstiny"]["CAUrl"] = CAURL
config["acmednstiny"]["ACMEDirectory"] = ACMEDIRECTORY
config["acmednstiny"]["CheckChallengeDelay"] = CHALLENGEDELAY
config["acmednstiny"]["MailContact"] = "mail@example.com"
del config["acmednstiny"]["PhoneContact"]
config["TSIGKeyring"]["KeyName"] = TSIGKEYNAME
config["TSIGKeyring"]["KeyValue"] = TSIGKEYVALUE
config["TSIGKeyring"]["Algorithm"] = TSIGALGORITHM
......@@ -98,6 +100,7 @@ def gen_config():
config.write(configfile)
return {
# configs
"goodCName": goodCName,
"dnsHostIP": dnsHostIP,
"goodSAN": goodSAN,
......@@ -105,11 +108,13 @@ def gen_config():
"accountAsDomain": accountAsDomain,
"invalidTSIGName": invalidTSIGName,
"missingDNS": missingDNS,
"key": {"accountkey": account_key,
"weakkey": weak_key,
"domainkey": domain_key},
"csr" : {"domaincsr": domain_csr,
"sancsr": san_csr,
"accountcsr": account_csr}
# keys (returned to keep files on system)
"accountkey": account_key,
"weakkey": weak_key,
"domainkey": domain_key,
# csr (returned to keep files on system)
"domaincsr": domain_csr,
"sancsr": san_csr,
"accountcsr": account_csr
}
......@@ -2,4 +2,4 @@ coverage
logassert
argparse
configparser
dnspython3
\ No newline at end of file
dnspython>=1.15
......@@ -6,11 +6,24 @@ from .config_maker import gen_config
from .acme_account_delete import delete_account
import logassert
CONFIGS = gen_config()
class TestModule(unittest.TestCase):
"Tests for acme_dns_tiny.get_crt()"
@classmethod
def setUpClass(self):
self.configs = gen_config()
super(TestModule, self).setUpClass()
# To clean ACME staging server and close correctly temporary files
@classmethod
def tearDownClass(self):
# delete account key registration at end of tests
delete_account(self.configs["accountkey"].name)
# close temp files correctly
for tmpfile in self.configs:
self.configs[tmpfile].close()
super(TestModule, self).tearDownClass()
def setUp(self):
logassert.setup(self, 'acme_dns_tiny_logger')
......@@ -18,7 +31,7 @@ class TestModule(unittest.TestCase):
""" Successfully issue a certificate via common name """
old_stdout = sys.stdout
sys.stdout = StringIO()
result = acme_dns_tiny.main([CONFIGS['goodCName'].name])
result = acme_dns_tiny.main([self.configs['goodCName'].name])
sys.stdout.seek(0)
crt = sys.stdout.read().encode("utf8")
sys.stdout = old_stdout
......@@ -31,7 +44,7 @@ class TestModule(unittest.TestCase):
""" When DNS Host is an IP, DNS resolution have to fail without error """
old_stdout = sys.stdout
sys.stdout = StringIO()
result = acme_dns_tiny.main([CONFIGS['dnsHostIP'].name])
result = acme_dns_tiny.main([self.configs['dnsHostIP'].name])
self.assertLoggedInfo("DNS IPv4 record not found for configured dns host.")
self.assertLoggedInfo("DNS IPv4 and IPv6 records not found for configured dns host.")
sys.stdout.seek(0)
......@@ -46,7 +59,7 @@ class TestModule(unittest.TestCase):
""" Successfully issue a certificate via subject alt name """
old_stdout = sys.stdout
sys.stdout = StringIO()
result = acme_dns_tiny.main([CONFIGS['goodSAN'].name])
result = acme_dns_tiny.main([self.configs['goodSAN'].name])
sys.stdout.seek(0)
crt = sys.stdout.read().encode("utf8")
sys.stdout = old_stdout
......@@ -58,7 +71,7 @@ class TestModule(unittest.TestCase):
def test_success_cli(self):
""" Successfully issue a certificate via command line interface """
crt, err = Popen([
"python3", "acme_dns_tiny.py", CONFIGS['goodCName'].name
"python3", "acme_dns_tiny.py", self.configs['goodCName'].name
], stdout=PIPE, stderr=PIPE).communicate()
out, err = Popen(["openssl", "x509", "-text", "-noout"], stdin=PIPE,
stdout=PIPE, stderr=PIPE).communicate(crt)
......@@ -68,7 +81,7 @@ class TestModule(unittest.TestCase):
def test_weak_key(self):
""" Let's Encrypt rejects weak keys """
try:
result = acme_dns_tiny.main([CONFIGS['weakKey'].name])
result = acme_dns_tiny.main([self.configs['weakKey'].name])
except Exception as e:
result = e
self.assertIsInstance(result, ValueError)
......@@ -77,7 +90,7 @@ class TestModule(unittest.TestCase):
def test_account_key_domain(self):
""" Can't use the account key for the CSR """
try:
result = acme_dns_tiny.main([CONFIGS['accountAsDomain'].name])
result = acme_dns_tiny.main([self.configs['accountAsDomain'].name])
except Exception as e:
result = e
self.assertIsInstance(result, ValueError)
......@@ -86,7 +99,7 @@ class TestModule(unittest.TestCase):
def test_failure_dns_update_tsigkeyname(self):
""" Fail to update DNS records by invalid TSIG Key name """
try:
result = acme_dns_tiny.main([CONFIGS['invalidTSIGName'].name])
result = acme_dns_tiny.main([self.configs['invalidTSIGName'].name])
except Exception as e:
result = e
self.assertIsInstance(result, ValueError)
......@@ -95,29 +108,11 @@ class TestModule(unittest.TestCase):
def test_failure_notcompleted_configuration(self):
""" Configuration file have to be completed """
try:
result = acme_dns_tiny.main([CONFIGS['missingDNS'].name])
result = acme_dns_tiny.main([self.configs['missingDNS'].name])
except Exception as e:
result = e
self.assertIsInstance(result, ValueError)
self.assertIn("Some required settings are missing.", result.args[0])
if __name__ == "__main__":
try:
unittest.main()
finally:
# delete account key registration at end of tests
delete_account(CONFIGS["key"]["accountkey"].name)
# close temp files correctly
CONFIGS["goodCName"].close()
CONFIGS["dnsHostIP"].close()
CONFIGS["goodSAN"].close()
CONFIGS["weakKey"].close()
CONFIGS["accountAsDomain"].close()
CONFIGS["invalidTSIGName"].close()
CONFIGS["missingDNS"].close()
CONFIGS["key"]["accountkey"].close()
CONFIGS["key"]["weakkey"].close()
CONFIGS["key"]["domainkey"].close()
CONFIGS["csr"]["domaincsr"].close()
CONFIGS["csr"]["sancsr"].close()
CONFIGS["csr"]["accountcsr"].close()
unittest.main()
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment