acme_account_delete.py 4.62 KB
Newer Older
1
import os, 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

    # 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(
48
        r"modulus:\r?\n\s+00:([a-f0-9\:\s]+?)\r?\npublicExponent: ([0-9]+)",
49
50
51
        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:])