acme_account_delete.py 4.61 KB
Newer Older
1
import argparse, subprocess, json, base64, binascii, re, copy, logging
2
from urllib.request import urlopen
3
from urllib.error import HTTPError
4

5
LOGGER = logging.getLogger("acme_account_delete")
6 7
LOGGER.addHandler(logging.StreamHandler())
LOGGER.setLevel(logging.INFO)
8

9
def account_delete(accountkeypath, acme_directory, log=LOGGER):
10
    # helper function base64 encode as defined in acme spec
11
    def _b64(b):
12
        return base64.urlsafe_b64encode(b).decode("utf8").rstrip("=")
13 14 15 16 17 18 19 20 21 22 23 24

    # helper function to run openssl command
    def _openssl(command, options, communicate=None):
        openssl = subprocess.Popen(["openssl", command] + options,
                                   stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        out, err = openssl.communicate(communicate)
        if openssl.returncode != 0:
            raise IOError("OpenSSL Error: {0}".format(err))
        return out

    # helper function make signed requests
    def _send_signed_request(url, payload):
25
        nonlocal jws_nonce
26
        payload64 = _b64(json.dumps(payload).encode("utf8"))
27
        protected = copy.deepcopy(jws_header)
28
        protected["nonce"] = jws_nonce or urlopen(acme_directory).getheader("Replay-Nonce", None)
29 30 31 32
        protected64 = _b64(json.dumps(protected).encode("utf8"))
        signature = _openssl("dgst", ["-sha256", "-sign", accountkeypath],
                             "{0}.{1}".format(protected64, payload64).encode("utf8"))
        data = json.dumps({
33
            "header": jws_header, "protected": protected64,
34 35 36 37
            "payload": payload64, "signature": _b64(signature),
        })
        try:
            resp = urlopen(url, data.encode("utf8"))
38
        except HTTPError as httperror:
39 40 41 42
            resp = httperror
        finally:
            jws_nonce = resp.getheader("Replay-Nonce", None)
            return resp.getcode(), resp.read(), resp.getheaders()
43 44 45 46 47 48 49 50 51

    # parse account key to get public key
    log.info("Parsing account key...")
    accountkey = _openssl("rsa", ["-in", accountkeypath, "-noout", "-text"])
    pub_hex, pub_exp = re.search(
        r"modulus:\n\s+00:([a-f0-9\:\s]+?)\npublicExponent: ([0-9]+)",
        accountkey.decode("utf8"), re.MULTILINE | re.DOTALL).groups()
    pub_exp = "{0:x}".format(int(pub_exp))
    pub_exp = "0{0}".format(pub_exp) if len(pub_exp) % 2 else pub_exp
52
    jws_header = {
53 54 55 56 57 58 59 60
        "alg": "RS256",
        "jwk": {
            "e": _b64(binascii.unhexlify(pub_exp.encode("utf-8"))),
            "kty": "RSA",
            "n": _b64(binascii.unhexlify(re.sub(r"(\s|:)", "", pub_hex).encode("utf-8"))),
        },
    }
    
61
    # get ACME server configuration from the directory
62
    directory = urlopen(acme_directory)
63
    acme_config = json.loads(directory.read().decode("utf8"))
64
    jws_nonce = None
65
    
66 67 68 69 70 71 72 73 74 75 76 77
    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))

78
    log.info("Delete account...")
79
    code, result, headers = _send_signed_request(account_url, {
80 81 82
        "resource": "reg",
        "delete": True,
    })
83 84

    if code not in [200,202]:
85 86
        raise ValueError("Error deleting account key: {0} {1}".format(code, result))
    log.info("Account key deleted !")
87 88 89 90

def main(argv):
    parser = argparse.ArgumentParser(
        formatter_class=argparse.RawDescriptionHelpFormatter,
91 92
        description="""
This script *deletes* your account from an ACME server.
93

94 95 96
It will need to have access to your private account key, so
PLEASE READ THROUGH IT!
It's around 150 lines, so it won't take long.
97

98 99 100 101
=== Example Usage ===
Remove account.key from staging Let's Encrypt:
python3 acme_account_delete.py --account-key account.key --acme-directory https://acme-staging.api.letsencrypt.org/directory
"""
102
    )
103 104
    parser.add_argument("--account-key", required = True, help="path to the private account key to delete")
    parser.add_argument("--acme-directory", required = True, help="ACME directory URL of the ACME server where to remove the key")
105
    parser.add_argument("--quiet", action="store_const",
106 107
                        const=logging.ERROR,
                        help="suppress output except for errors")
108 109 110 111 112 113 114
    args = parser.parse_args(argv)

    LOGGER.setLevel(args.quiet or LOGGER.level)
    account_delete(args.account_key, args.acme_directory)

if __name__ == "__main__":  # pragma: no cover
    main(sys.argv[1:])