Commit 66478531 authored by Adrien Dorsaz's avatar Adrien Dorsaz

Merge branch 'v2.1-acme-draft-16' into 'master'

V2.1 acme draft 16, ready order status, general rework

See merge request !13
parents 5ff96857 bff2790f
Pipeline #225 passed with stage
in 10 minutes and 3 seconds
.PHONY: requirements unit_test_acme_dns_tiny_success_san unit_test_acme_account_rollover unit_test_acme_account_deactivate
DEFAULT: requirements
unit_test_acme_dns_tiny_success_san:
python3 -m unittest tests.test_acme_dns_tiny.TestACMEDNSTiny.test_success_san
unit_test_acme_account_rollover:
python3 -m unittest tests.test_acme_account_rollover.TestACMEAccountRollover.test_success_account_rollover
unit_test_acme_account_deactivate:
python3 -m unittest tests.test_acme_account_deactivate.TestACMEAccountDeactivate.test_success_account_deactivate
unit_test_all_with_coverage:
python3-coverage run --source ./ -m unittest -v tests.test_acme_dns_tiny tests.test_acme_account_rollover tests.test_acme_account_deactivate
python3-coverage report --include=acme_dns_tiny.py,tools/acme_account_rollover.py,tools/acme_account_deactivate.py
python3-coverage html
requirements:
pip3 install --user --upgrade -r tests/requirements.txt
......@@ -6,12 +6,12 @@ LOGGER = logging.getLogger('acme_dns_tiny')
LOGGER.addHandler(logging.StreamHandler())
def get_crt(config, log=LOGGER):
# helper function base64 encode as defined in acme spec
def _b64(b):
""""Encodes string as base64 as specified in ACME RFC """
return base64.urlsafe_b64encode(b).decode("utf8").rstrip("=")
# helper function to run openssl command
def _openssl(command, options, communicate=None):
"""Run openssl command line and raise IOError on non-zero return."""
openssl = subprocess.Popen(["openssl", command] + options,
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
out, err = openssl.communicate(communicate)
......@@ -19,22 +19,25 @@ def get_crt(config, log=LOGGER):
raise IOError("OpenSSL Error: {0}".format(err))
return out
# helper function to send DNS dynamic update messages
def _update_dns(rrset, action):
"""Updates DNS resource by adding or deleting resource."""
algorithm = dns.name.from_text("{0}".format(config["TSIGKeyring"]["Algorithm"].lower()))
dns_update = dns.update.Update(config["DNS"]["zone"], keyring=keyring, keyalgorithm=algorithm)
if action == "add":
dns_update.add(rrset.name, rrset)
elif action == "delete":
dns_update.delete(rrset.name, rrset)
resp = dns.query.tcp(dns_update, config["DNS"]["Host"], port=config.getint("DNS", "Port"))
response = dns.query.tcp(dns_update, config["DNS"]["Host"], port=config.getint("DNS", "Port"))
dns_update = None
return resp
return response
# helper function to send signed requests
def _send_signed_request(url, payload):
"""Sends signed requests to ACME server."""
nonlocal jws_nonce
payload64 = _b64(json.dumps(payload).encode("utf8"))
if payload == "": # on POST-as-GET, final payload has to be just empty string
payload64 = ""
else:
payload64 = _b64(json.dumps(payload).encode("utf8"))
protected = copy.deepcopy(jws_header)
protected["nonce"] = jws_nonce or requests.get(acme_config["newNonce"]).headers['Replay-Nonce']
protected["url"] = url
......@@ -49,18 +52,18 @@ def get_crt(config, log=LOGGER):
"protected": protected64, "payload": payload64,"signature": _b64(signature)
}
try:
resp = requests.post(url, json=jose, headers=joseheaders)
response = requests.post(url, json=jose, headers=joseheaders)
except requests.exceptions.RequestException as error:
resp = error.response
response = error.response
finally:
jws_nonce = resp.headers['Replay-Nonce']
if resp.text != '':
return resp.status_code, resp.json(), resp.headers
else:
return resp.status_code, json.dumps({}), resp.headers
jws_nonce = response.headers['Replay-Nonce']
try:
return response, response.json()
except ValueError as error:
return response, json.dumps({})
# main code
adtheaders = {'User-Agent': 'acme-dns-tiny/2.0',
adtheaders = {'User-Agent': 'acme-dns-tiny/2.1',
'Accept-Language': config["acmednstiny"].get("Language", "en")
}
joseheaders=copy.deepcopy(adtheaders)
......@@ -102,13 +105,13 @@ def get_crt(config, log=LOGGER):
"kid": None,
}
accountkey_json = json.dumps(jws_header["jwk"], sort_keys=True, separators=(",", ":"))
thumbprint = _b64(hashlib.sha256(accountkey_json.encode("utf8")).digest())
jwk_thumbprint = _b64(hashlib.sha256(accountkey_json.encode("utf8")).digest())
jws_nonce = None
log.info("Read CSR to find domains to validate.")
csr = _openssl("req", ["-in", config["acmednstiny"]["CSRFile"], "-noout", "-text"]).decode("utf8")
domains = set([])
common_name = re.search(r"Subject:\s*?CN\s*?=\s*?([^\s,;/]+)", csr)
domains = set()
common_name = re.search(r"Subject:.*?\s+?CN\s*?=\s*?([^\s,;/]+)", csr)
if common_name is not None:
domains.add(common_name.group(1))
subject_alt_names = re.search(r"X509v3 Subject Alternative Name: \r?\n +([^\r\n]+)\r?\n", csr, re.MULTILINE | re.DOTALL)
......@@ -128,60 +131,58 @@ def get_crt(config, log=LOGGER):
if account_request["contact"] == "":
del account_request["contact"]
code, result, headers = _send_signed_request(acme_config["newAccount"], account_request)
account_info = {}
if code == 201:
jws_header["kid"] = headers['Location']
http_response, account_info = _send_signed_request(acme_config["newAccount"], account_request)
if http_response.status_code == 201:
jws_header["kid"] = http_response.headers['Location']
log.info(" - Registered a new account: '{0}'".format(jws_header["kid"]))
account_info = result
elif code == 200:
jws_header["kid"] = headers['Location']
elif http_response.status_code == 200:
jws_header["kid"] = http_response.headers['Location']
log.debug(" - Account is already registered: '{0}'".format(jws_header["kid"]))
code, result, headers = _send_signed_request(jws_header["kid"], {})
account_info = result
http_response, account_info = _send_signed_request(jws_header["kid"], {})
else:
raise ValueError("Error registering account: {0} {1}".format(code, result))
raise ValueError("Error registering account: {0} {1}".format(http_response.status_code, account_info))
log.info("Update contact information if needed.")
if (set(account_request["contact"]) != set(account_info["contact"])):
code, result, headers = _send_signed_request(jws_header["kid"], account_request)
if code == 200:
http_response, result = _send_signed_request(jws_header["kid"], account_request)
if http_response.status_code == 200:
log.debug(" - Account updated with latest contact informations.")
else:
raise ValueError("Error registering updates for the account: {0} {1}".format(code, result))
raise ValueError("Error registering updates for the account: {0} {1}".format(http_response.status_code, result))
# new order
log.info("Request to the ACME server an order to validate domains.")
new_order = { "identifiers": [{"type": "dns", "value": domain} for domain in domains]}
code, result, headers = _send_signed_request(acme_config["newOrder"], new_order)
order = result
if code == 201:
order_location = headers['Location']
http_response, order = _send_signed_request(acme_config["newOrder"], new_order)
if http_response.status_code == 201:
order_location = http_response.headers['Location']
log.debug(" - Order received: {0}".format(order_location))
if order["status"] != "pending":
raise ValueError("Order status is not pending, we can't use it: {0}".format(order))
elif (code == 403
if order["status"] != "pending" and order["status"] != "ready":
raise ValueError("Order status is neither pending neither ready, we can't use it: {0}".format(order))
elif (http_response.status_code == 403
and order["type"] == "urn:ietf:params:acme:error:userActionRequired"):
raise ValueError("Order creation failed ({0}). Read Terms of Service ({1}), then follow your CA instructions: {2}".format(order["detail"], headers['Link'], order["instance"]))
raise ValueError("Order creation failed ({0}). Read Terms of Service ({1}), then follow your CA instructions: {2}".format(order["detail"], http_response.headers['Link'], order["instance"]))
else:
raise ValueError("Error getting new Order: {0} {1}".format(code, result))
raise ValueError("Error getting new Order: {0} {1}".format(http_response.status_code, result))
# complete each authorization challenge
for authz in order["authorizations"]:
log.info("Process challenge for authorization: {0}".format(authz))
if order["status"] == "ready":
log.info("No challenge to process: order is already ready.")
break;
log.info("Process challenge for authorization: {0}".format(authz))
# get new challenge
resp = requests.get(authz, headers=adtheaders)
authorization = resp.json()
if resp.status_code != 200:
raise ValueError("Error fetching challenges: {0} {1}".format(resp.status_code, authorization))
http_response, authorization = _send_signed_request(authz, "")
if http_response.status_code != 200:
raise ValueError("Error fetching challenges: {0} {1}".format(http_response.status_code, authorization))
domain = authorization["identifier"]["value"]
log.info("Install DNS TXT resource for domain: {0}".format(domain))
challenge = [c for c in authorization["challenges"] if c["type"] == "dns-01"][0]
token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge["token"])
keyauthorization = "{0}.{1}".format(token, thumbprint)
keyauthorization = "{0}.{1}".format(token, jwk_thumbprint)
keydigest64 = _b64(hashlib.sha256(keyauthorization.encode("utf8")).digest())
dnsrr_domain = "_acme-challenge.{0}.".format(domain)
try: # a CNAME resource can be used for advanced TSIG configuration
......@@ -216,17 +217,15 @@ def get_crt(config, log=LOGGER):
time.sleep(config["DNS"].getint("TTL"))
log.info("Asking ACME server to validate challenge.")
code, result, headers = _send_signed_request(challenge["url"], {"keyAuthorization": keyauthorization})
if code != 200:
raise ValueError("Error triggering challenge: {0} {1}".format(code, result))
http_response, result = _send_signed_request(challenge["url"], {"keyAuthorization": keyauthorization})
if http_response.status_code != 200:
raise ValueError("Error triggering challenge: {0} {1}".format(http_response.status_code, result))
try:
while True:
try:
resp = requests.get(challenge["url"], headers=adtheaders)
challenge_status = resp.json()
except requests.exceptions.RequestException as error:
http_response, challenge_status = _send_signed_request(challenge["url"], "")
if http_response.status_code != 200:
raise ValueError("Error during challenge validation: {0} {1}".format(
error.response.status_code, error.response.text()))
http_response.status_code, challenge_status))
if challenge_status["status"] == "pending":
time.sleep(2)
elif challenge_status["status"] == "valid":
......@@ -239,42 +238,36 @@ def get_crt(config, log=LOGGER):
_update_dns(dnsrr_set, "delete")
log.info("Request to finalize the order (all chalenge have been completed)")
resp = requests.get(order_location, headers=adtheaders)
finalize = resp.json()
csr_der = _b64(_openssl("req", ["-in", config["acmednstiny"]["CSRFile"], "-outform", "DER"]))
code, result, headers = _send_signed_request(order["finalize"], {"csr": csr_der})
if code != 200:
raise ValueError("Error while sending the CSR: {0} {1}".format(code, result))
http_response, result = _send_signed_request(order["finalize"], {"csr": csr_der})
if http_response.status_code != 200:
raise ValueError("Error while sending the CSR: {0} {1}".format(http_response.status_code, result))
while True:
try:
resp = requests.get(order_location, headers=adtheaders)
resp.raise_for_status()
finalize = resp.json()
except requests.exceptions.RequestException as error:
raise ValueError("Error finalizing order: {0} {1}".format(
error.response.status_code, error.response.text()))
http_response, order = _send_signed_request(order_location, "")
if finalize["status"] == "processing":
if resp.headers["Retry-After"]:
time.sleep(resp.headers["Retry-After"])
if order["status"] == "processing":
if http_response.headers["Retry-After"]:
time.sleep(http_response.headers["Retry-After"])
else:
time.sleep(2)
elif finalize["status"] == "valid":
elif order["status"] == "valid":
log.info("Order finalized!")
break
else:
raise ValueError("Finalizing order {0} got errors: {1}".format(
domain, finalize))
resp = requests.get(finalize["certificate"], headers=adtheaders)
if resp.status_code != 200:
raise ValueError("Finalizing order {0} got errors: {1}".format(
resp.status_code, resp.json()))
certchain = resp.text
domain, order))
log.info("Certificate signed and chain received: {0}".format(finalize["certificate"]))
return certchain
joseheaders['Accept'] = config["acmednstiny"].get("CertificateFormat", 'application/pem-certificate-chain')
http_response, result = _send_signed_request(order["certificate"], "")
if http_response.status_code != 200:
raise ValueError("Finalizing order {0} got errors: {1}".format(http_response.status_code, result))
if 'link' in http_response.headers:
log.info(" - Certificate links given by server: {0}", http_response.headers['link'])
log.info("Certificate signed and chain received: {0}".format(order["certificate"]))
return http_response.text
def main(argv):
parser = argparse.ArgumentParser(
......
......@@ -8,7 +8,7 @@ CSRFile = domain.csr
# Optional ACME directory url
# Default: https://acme-staging-v02.api.letsencrypt.org/directory
ACMEDirectory = https://acme-staging-v02.api.letsencrypt.org/directory
#ACMEDirectory = https://acme-staging-v02.api.letsencrypt.org/directory
# Optional To be able to be reached by ACME provider (e.g. to warn about
# certificate expicration), you can provide some contact informations.
......@@ -18,12 +18,24 @@ ACMEDirectory = https://acme-staging-v02.api.letsencrypt.org/directory
# For the mailto URI, the email address part must contains only one address
# without header fields (see [RFC6068]).
# Default: none
Contacts = mailto:mail@example.com;mailto:mail2@example.org
#Contacts = mailto:mail@example.com;mailto:mail2@example.org
# Optional to give hint to the ACME server about your prefered language for errors given by their server
# See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language for more informations
# Default: en
Language = en
#Language = en
# Optional: ask to request different format of certificate file.
# By default, acme-dns-tiny request a certificate chain with format
# "application/pem-certificate-chain"
# With this format, you can assume the first certificate block is the one for
# your domains, as the ACME RFC force the format to have this certificate first.
#
# If the ACME server support different format, you can specify it here
# (e.g. application/pkix-cert, applicaiton/pkcs7-mime)
# Note that, if the format selected doesn't provide a full chain, you should
# read logs to find the related certificates (see link header with attribute rel=up)
#CertificateFormat = application/pem-certificate-chain
[TSIGKeyring]
# Required TSIG key name
......@@ -43,11 +55,11 @@ Zone = dnszone
Host = dnsserver
# Optional port to connect on DNS server (default: 53)
Port = 53
#Port = 53
# Optional time to live (TTL) value used to add DNS entries
# For each domain registered in the CSR, at least 1 TTL is waited before certificate creation.
# If an error occurs while looking for TXT records, we wait up to 10 TTLs by domain.
# That's why the default is only of 10 seconds, to avoid having too long time to wait to receive a new certificate.
# Default: 10 seconds
TTL = 10
#TTL = 10
......@@ -13,6 +13,7 @@ DNSTTL = os.getenv("GITLABCI_DNSTTL", "10")
TSIGKEYNAME = os.getenv("GITLABCI_TSIGKEYNAME")
TSIGKEYVALUE = os.getenv("GITLABCI_TSIGKEYVALUE")
TSIGALGORITHM = os.getenv("GITLABCI_TSIGALGORITHM")
CONTACT = os.getenv("GITLABCI_CONTACT")
# generate simple config
def generate_config():
......@@ -32,7 +33,11 @@ def generate_config():
parser["acmednstiny"]["AccountKeyFile"] = account_key.name
parser["acmednstiny"]["CSRFile"] = domain_csr.name
parser["acmednstiny"]["ACMEDirectory"] = ACMEDIRECTORY
parser["acmednstiny"]["Contacts"] = "mailto:mail@example.com"
if (CONTACT is not None
and CONTACT != ""):
parser["acmednstiny"]["Contacts"] = "mailto:{0}".format(CONTACT)
else:
del parser["acmednstiny"]["Contacts"]
parser["TSIGKeyring"]["KeyName"] = TSIGKEYNAME
parser["TSIGKeyring"]["KeyValue"] = TSIGKEYVALUE
parser["TSIGKeyring"]["Algorithm"] = TSIGALGORITHM
......@@ -41,98 +46,122 @@ def generate_config():
parser["DNS"]["Zone"] = DNSZONE
parser["DNS"]["TTL"] = DNSTTL
config = NamedTemporaryFile(delete=False)
with open(config.name, 'w') as configfile:
parser.write(configfile)
return account_key.name, domain_key.name, domain_csr.name, config.name
return account_key.name, domain_key.name, domain_csr.name, parser
# generate account and domain keys
def generate_acme_dns_tiny_config():
# Simple good configuration
account_key, domain_key, domain_csr, goodCName = generate_config();
# CSR for good configuration with wildcard domain
wilddomain_csr = NamedTemporaryFile(delete=False)
Popen(["openssl", "req", "-newkey", "rsa:2048", "-nodes", "-keyout", domain_key,
"-subj", "/CN=*.{0}".format(DOMAIN), "-out", wilddomain_csr.name]).wait()
# weak 1024 bit account key
weak_key = NamedTemporaryFile(delete=False)
Popen(["openssl", "genrsa", "-out", weak_key.name, "1024"]).wait()
# CSR using subject alt-name domain instead of CN (common name)
san_csr = NamedTemporaryFile(delete=False)
san_conf = NamedTemporaryFile(delete=False)
san_conf.write(open("/etc/ssl/openssl.cnf").read().encode("utf8"))
san_conf.write("\n[SAN]\nsubjectAltName=DNS:{0},DNS:www.{0}\n".format(DOMAIN).encode("utf8"))
san_conf.seek(0)
Popen(["openssl", "req", "-new", "-sha256", "-key", domain_key,
"-subj", "/", "-reqexts", "SAN", "-config", san_conf.name,
"-out", san_csr.name]).wait()
# Simple configuration with good options
account_key, domain_key, domain_csr, config = generate_config();
os.remove(domain_key)
# CSR using wildcard in subject alt-name domain
wildsan_csr = NamedTemporaryFile(delete=False)
wildsan_conf = NamedTemporaryFile(delete=False)
wildsan_conf.write(open("/etc/ssl/openssl.cnf").read().encode("utf8"))
wildsan_conf.write("\n[SAN]\nsubjectAltName=DNS:{0},DNS:*.{0}\n".format(DOMAIN).encode("utf8"))
wildsan_conf.seek(0)
Popen(["openssl", "req", "-new", "-sha256", "-key", domain_key,
"-subj", "/", "-reqexts", "SAN", "-config", wildsan_conf.name,
"-out", wildsan_csr.name]).wait()
goodCName = NamedTemporaryFile(delete=False)
with open(goodCName.name, 'w') as configfile:
config.write(configfile)
# CSR signed with the account key
account_csr = NamedTemporaryFile(delete=False)
Popen(["openssl", "req", "-new", "-sha256", "-key", account_key,
"-subj", "/CN={0}".format(DOMAIN), "-out", account_csr.name]).wait()
# Simple configuration without CSR in configuration (will be passed as argument)
account_key, domain_key, domain_csr, config = generate_config();
os.remove(domain_key)
# Create config parser from the good default config to generate custom configs
config = configparser.ConfigParser()
config.read(goodCName)
cnameCSR = domain_csr
config.remove_option("acmednstiny", "CSRFile")
goodCNameWithoutCSR = NamedTemporaryFile(delete=False)
config.remove_option("acmednstiny", "CSRFile")
with open(goodCNameWithoutCSR.name, 'w') as configfile:
config.write(configfile)
# Configuration with CSR containing a wildcard domain
account_key, domain_key, domain_csr, config = generate_config();
Popen(["openssl", "req", "-newkey", "rsa:2048", "-nodes", "-keyout", domain_key,
"-subj", "/CN=*.{0}".format(DOMAIN), "-out", domain_csr]).wait()
os.remove(domain_key)
wildCName = NamedTemporaryFile(delete=False)
config["acmednstiny"]["CSRFile"] = wilddomain_csr.name
with open(wildCName.name, 'w') as configfile:
config.write(configfile)
dnsHostIP = NamedTemporaryFile(delete=False)
# Configuration with IP as DNS Host
account_key, domain_key, domain_csr, config = generate_config();
os.remove(domain_key)
config["DNS"]["Host"] = DNSHOSTIP
dnsHostIP = NamedTemporaryFile(delete=False)
with open(dnsHostIP.name, 'w') as configfile:
config.write(configfile)
config["DNS"]["Host"] = DNSHOST
# Configuration with CSR using subject alt-name domain instead of CN (common name)
account_key, domain_key, domain_csr, config = generate_config();
san_conf = NamedTemporaryFile(delete=False)
with open("/etc/ssl/openssl.cnf", 'r') as opensslcnf:
san_conf.write(opensslcnf.read().encode("utf8"))
san_conf.write("\n[SAN]\nsubjectAltName=DNS:{0},DNS:www.{0}\n".format(DOMAIN).encode("utf8"))
san_conf.seek(0)
Popen(["openssl", "req", "-new", "-sha256", "-key", domain_key,
"-subj", "/", "-reqexts", "SAN", "-config", san_conf.name,
"-out", domain_csr]).wait()
os.remove(san_conf.name)
os.remove(domain_key)
goodSAN = NamedTemporaryFile(delete=False)
config["acmednstiny"]["CSRFile"] = san_csr.name
with open(goodSAN.name, 'w') as configfile:
config.write(configfile)
# Configuration with CSR containing a wildcard domain inside subjetcAltName
account_key, domain_key, domain_csr, config = generate_config();
wildsan_conf = NamedTemporaryFile(delete=False)
with open("/etc/ssl/openssl.cnf", 'r') as opensslcnf:
wildsan_conf.write(opensslcnf.read().encode("utf8"))
wildsan_conf.write("\n[SAN]\nsubjectAltName=DNS:{0},DNS:*.{0}\n".format(DOMAIN).encode("utf8"))
wildsan_conf.seek(0)
Popen(["openssl", "req", "-new", "-sha256", "-key", domain_key,
"-subj", "/", "-reqexts", "SAN", "-config", wildsan_conf.name,
"-out", domain_csr]).wait()
os.remove(wildsan_conf.name)
os.remove(domain_key)
wildSAN = NamedTemporaryFile(delete=False)
config["acmednstiny"]["CSRFile"] = wildsan_csr.name
with open(wildSAN.name, 'w') as configfile:
config.write(configfile)
# Bad configuration with weak 1024 bit account key
account_key, domain_key, domain_csr, config = generate_config();
os.remove(domain_key)
Popen(["openssl", "genrsa", "-out", account_key, "1024"]).wait()
weakKey = NamedTemporaryFile(delete=False)
config["acmednstiny"]["AccountKeyFile"] = weak_key.name
config["acmednstiny"]["CSRFile"] = domain_csr
with open(weakKey.name, 'w') as configfile:
config.write(configfile)
# Bad configuration with account key as domain key
account_key, domain_key, domain_csr, config = generate_config();
os.remove(domain_key)
# Create a new CSR signed with the account key instead of domain key
Popen(["openssl", "req", "-new", "-sha256", "-key", account_key,
"-subj", "/CN={0}".format(DOMAIN), "-out", domain_csr]).wait()
accountAsDomain = NamedTemporaryFile(delete=False)
config["acmednstiny"]["AccountKeyFile"] = account_key
config["acmednstiny"]["CSRFile"] = account_csr.name
with open(accountAsDomain.name, 'w') as configfile:
config.write(configfile)
# Create config parser from the good default config to generate custom configs
account_key, domain_key, domain_csr, config = generate_config();
os.remove(domain_key)
invalidTSIGName = NamedTemporaryFile(delete=False)
config["TSIGKeyring"]["KeyName"] = "{0}.invalid".format(TSIGKEYNAME)
with open(invalidTSIGName.name, 'w') as configfile:
config.write(configfile)
# Create config parser from the good default config to generate custom configs
account_key, domain_key, domain_csr, config = generate_config();
os.remove(domain_key)
missingDNS = NamedTemporaryFile(delete=False)
config["DNS"] = {}
with open(missingDNS.name, 'w') as configfile:
......@@ -140,7 +169,7 @@ def generate_acme_dns_tiny_config():
return {
# configs
"goodCName": goodCName,
"goodCName": goodCName.name,
"goodCNameWithoutCSR": goodCNameWithoutCSR.name,
"wildCName": wildCName.name,
"dnsHostIP": dnsHostIP.name,
......@@ -150,9 +179,7 @@ def generate_acme_dns_tiny_config():
"accountAsDomain": accountAsDomain.name,
"invalidTSIGName": invalidTSIGName.name,
"missingDNS": missingDNS.name,
# key (just to simply remove the account from staging server)
"accountkey": account_key,
# CName CSR file to use with goodCNameWithoutCSR
# CName CSR file to use with goodCNameWithoutCSR as argument
"cnameCSR": domain_csr,
}
......@@ -160,14 +187,19 @@ def generate_acme_dns_tiny_config():
def generate_acme_account_rollover_config():
# Old account is directly created by the config generator
old_account_key, domain_key, domain_csr, config = generate_config()
os.remove(domain_key)
# New account key
new_account_key = NamedTemporaryFile(delete=False)
Popen(["openssl", "genrsa", "-out", new_account_key.name, "2048"]).wait()
rolloverAccount = NamedTemporaryFile(delete=False)
with open(rolloverAccount.name, 'w') as configfile:
config.write(configfile)
return {
# config and keys (returned to keep files on system)
"config": config,
"config": rolloverAccount.name,
"oldaccountkey": old_account_key,
"newaccountkey": new_account_key.name
}
......@@ -176,8 +208,13 @@ def generate_acme_account_rollover_config():
def generate_acme_account_deactivate_config():
# Account key is created by the by the config generator
account_key, domain_key, domain_csr, config = generate_config()
os.remove(domain_key)
deactivateAccount = NamedTemporaryFile(delete=False)
with open(deactivateAccount.name, 'w') as configfile:
config.write(configfile)
return {
"config": config,
"config": deactivateAccount.name,
"key": account_key
}
import unittest, os, time
import unittest, os, time, configparser
import acme_dns_tiny
from tests.config_factory import generate_acme_account_deactivate_config
import tools.acme_account_deactivate
......@@ -23,8 +23,14 @@ class TestACMEAccountDeactivate(unittest.TestCase):
@classmethod
def tearDownClass(self):
# Remove temporary files
os.remove(self.configs['config'])
os.remove(self.configs['key'])
parser = configparser.ConfigParser()
parser.read(self.configs['config'])
try:
os.remove(parser["acmednstiny"]["AccountKeyFile"])
os.remove(parser["acmednstiny"]["CSRFile"])
os.remove(self.configs['config'])
except:
pass
super(TestACMEAccountDeactivate, self).tearDownClass()
def test_success_account_deactivate(self):
......
import unittest, os, time
import unittest, os, time, configparser
import acme_dns_tiny
from tests.config_factory import generate_acme_account_rollover_config
from tools.acme_account_deactivate import account_deactivate
......@@ -19,10 +19,18 @@ class TestACMEAccountRollover(unittest.TestCase):
@classmethod
def tearDownClass(self):
# deactivate account key registration at end of tests
account_deactivate(self.configs["oldaccountkey"], ACMEDirectory)
# close temp files correctly
for tmpfile in self.configs:
os.remove(self.configs[tmpfile])
# (we assume the key has been roll oved)
account_deactivate(self.configs["newaccountkey"], ACMEDirectory)
# Remove temporary files
parser = configparser.ConfigParser()
parser.read(self.configs['config'])
try:
os.remove(parser["acmednstiny"]["AccountKeyFile"])
os.remove(parser["acmednstiny"]["CSRFile"])
os.remove(self.configs["newaccountkey"])
os.remove(self.configs['config'])
except:
pass
super(TestACMEAccountRollover, self).tearDownClass()
def test_success_account_rollover(self):
......
import unittest, sys, os, subprocess, time
import unittest, sys, os, subprocess, time, configparser
from io import StringIO
import dns.version
import acme_dns_tiny
......@@ -22,11 +22,19 @@ class TestACMEDNSTiny(unittest.TestCase):
# To clean ACME staging server and close correctly temporary files
@classmethod
def tearDownClass(self):
# deactivate account key registration at end of tests
account_deactivate(self.configs["accountkey"], ACMEDirectory)
# close temp files correctly
for tmpfile in self.configs:
os.remove(self.configs[tmpfile])
for conffile in self.configs:
parser = configparser.ConfigParser()
parser.read(conffile)
try:
os.remove(parser["acmednstiny"]["AccountKeyFile"])
os.remove(parser["acmednstiny"]["CSRFile"])
# for each configuraiton, deactivate the account key
if conffile != "cnameCSR":
account_deactivate(parser["acmednstiny"]["AccountKeyFile"], ACMEDirectory)
os.remove(conffile)
except:
pass
super(TestACMEDNSTiny, self).tearDownClass()
# helper function to run openssl command
......
......@@ -5,12 +5,12 @@ LOGGER = logging.getLogger("acme_account_deactivate")
LOGGER.addHandler(logging.StreamHandler())
def account_deactivate(accountkeypath, acme_directory, log=LOGGER):
# helper function base64 encode as defined in acme spec
def _b64(b):
""""Encodes string as base64 as specified in ACME RFC """
return base64.urlsafe_b64encode(b).decode("utf8").rstrip("=")
# helper function to run openssl command
def _openssl(command, options, communicate=None):
"""Run openssl command line and raise IOError on non-zero return."""
openssl = subprocess.Popen(["openssl", command] + options,
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
out, err = openssl.communicate(communicate)
......@@ -18,10 +18,13 @@ def account_deactivate(accountkeypath, acme_directory, log=LOGGER):
raise IOError("OpenSSL Error: {0}".format(err))
return out
# helper function to send signed requests
def _send_signed_request(url, payload):
"""Sends signed requests to ACME server."""
nonlocal jws_nonce
payload64 = _b64(json.dumps(payload).encode("utf8"))
if payload == "": # on POST-as-GET, final payload has to be just empty string
payload64 = ""
else:
payload64 = _b64(json.dumps(payload).encode("utf8"))
protected = copy.deepcopy(jws_header)
protected["nonce"] = jws_nonce or requests.get(acme_config["newNonce"]).headers['Replay-Nonce']
protected["url"] = url
......@@ -32,22 +35,22 @@ def account_deactivate(accountkeypath, acme_directory, log=LOGGER):
protected64 = _b64(json.dumps(protected).encode("utf8"))
signature = _openssl("dgst", ["-sha256", "-sign", accountkeypath],
"{0}.{1}".format(protected64, payload64).encode("utf8"))
jws = {
"protected": protected64, "payload": payload64, "signature": _b64(signature)
jose = {
"protected": protected64, "payload": payload64,"signature": _b64(signature)
}
try:
resp = requests.post(url, json=jws, headers=joseheaders)
response = requests.post(url, json=jose, headers=joseheaders)
except requests.exceptions.RequestException as error: