Skip to content

Commit

Permalink
Implement externalAccountBinding
Browse files Browse the repository at this point in the history
https://datatracker.ietf.org/doc/html/rfc8555#section-7.3.4

Also send contact at register time if available (some CAs mandate this)
  • Loading branch information
systemcrash committed Sep 6, 2021
1 parent 0a9afb2 commit e0df39f
Show file tree
Hide file tree
Showing 2 changed files with 44 additions and 8 deletions.
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,21 @@ and read your private account key and CSR.
python acme_tiny.py --account-key ./account.key --csr ./domain.csr --acme-dir /var/www/challenges/ > ./signed_chain.crt
```

If your ACME CA mandates externalAccountBinding (eAB), provide those parameters like so:

```
# Run the script on your server
python acme_tiny.py --account-key ./account.key --csr ./domain.csr --acme-dir /var/www/challenges/ --eabkid 'PAtzxcSFQMQSdm9SLJTxCt0hwvvl5yNKPfnWBWqPk8o' --eabhmackey 'ZndUSkZvVldvMEFiRzQ5VWNCdERtNkNBNnBTcTl4czNKVEVxdUZiaEdpZXZNUVJBVmRuSFREcDJYX2s3X0NxTA' > ./signed_chain.crt
```

Some ACME CA mandate a contact at registration:

```
# Run the script on your server
python acme_tiny.py --account-key ./account.key --csr ./domain.csr --acme-dir /var/www/challenges/ --contact [email protected] --eabkid 'PAtzxcSFQMQSdm9SLJTxCt0hwvvl5yNKPfnWBWqPk8o' --eabhmackey 'ZndUSkZvVldvMEFiRzQ5VWNCdERtNkNBNnBTcTl4czNKVEVxdUZiaEdpZXZNUVJBVmRuSFREcDJYX2s3X0NxTA' > ./signed_chain.crt
```


### Step 5: Install the certificate

The signed https certificate chain that is output by this script can be used along
Expand Down
37 changes: 29 additions & 8 deletions acme_tiny.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env python
# Copyright Daniel Roesler, under MIT license, see LICENSE at github.com/diafygi/acme-tiny
import argparse, subprocess, json, os, sys, base64, binascii, time, hashlib, re, copy, textwrap, logging
import argparse, subprocess, json, os, sys, base64, binascii, time, hashlib, re, copy, textwrap, logging, hmac
try:
from urllib.request import urlopen, Request # Python 3
except ImportError:
Expand All @@ -13,7 +13,7 @@
LOGGER.addHandler(logging.StreamHandler())
LOGGER.setLevel(logging.INFO)

def get_crt(account_key, csr, acme_dir, log=LOGGER, CA=DEFAULT_CA, disable_check=False, directory_url=DEFAULT_DIRECTORY_URL, contact=None):
def get_crt(account_key, csr, acme_dir, log=LOGGER, CA=DEFAULT_CA, disable_check=False, directory_url=DEFAULT_DIRECTORY_URL, contact=None, eabkid=None, eabhmackey=None):
directory, acct_headers, alg, jwk = None, None, None, None # global variables

# helper functions - base64 encode for jose spec
Expand Down Expand Up @@ -70,6 +70,17 @@ def _poll_until_not(url, pending_statuses, err_msg):
result, _, _ = _send_signed_request(url, None, err_msg)
return result

# helper function - build the eAB: externalAccountBinding
def _build_eab(url, eabkid, eabhmackey, jwk):
try: # Decode to verify HMAC b64 is good. Pad b64 string with '='. Py3 strips extra pad.
eabhmackey = base64.urlsafe_b64decode(eabhmackey.strip() + '==') # hmac broken anyway if len%4 != 0
except (binascii.Error, TypeError): # T-E = py2 (incorrect b64 padding)
log.info("Error verifying eAB HMAC.")
protected64 = _b64(json.dumps({"alg": "HS256", "kid": eabkid, "url": url}).encode('utf-8'))
payload64 = _b64(json.dumps(jwk).encode('utf-8'))
signed64 = _b64(hmac.new(eabhmackey, (protected64 + "." + payload64).encode('utf-8'), digestmod=hashlib.sha256).digest())
return {"protected": protected64, "payload": payload64, "signature": signed64}

# parse account key to get public key
log.info("Parsing account key...")
out = _cmd(["openssl", "rsa", "-in", account_key, "-noout", "-text"], err_msg="OpenSSL Error")
Expand Down Expand Up @@ -109,11 +120,19 @@ def _poll_until_not(url, pending_statuses, err_msg):
# create account, update contact details (if any), and set the global key identifier
log.info("Registering account...")
reg_payload = {"termsOfServiceAgreed": True}
account, code, acct_headers = _send_signed_request(directory['newAccount'], reg_payload, "Error registering")
if eabkid and eabhmackey: # https://datatracker.ietf.org/doc/html/rfc8555#section-7.3.4
log.info("Building externalAccountBinding...")
reg_payload['externalAccountBinding'] = _build_eab(directory['newAccount'], eabkid, eabhmackey, jwk)
if contact: # some providers, e.g. buypass mandate contact at registration
reg_payload["contact"] = contact
response, code, acct_headers = _send_signed_request(directory['newAccount'], reg_payload, "Error registering")
log.info("Registered!" if code == 201 else "Already registered!")
if contact is not None:
account, _, _ = _send_signed_request(acct_headers['Location'], {"contact": contact}, "Error updating contact details")
log.info("Updated contact details:\n{0}".format("\n".join(account['contact'])))
if contact and code == 200: # 200 == already reg --> update
response, _, _ = _send_signed_request(acct_headers['Location'], {"contact": contact}, "Error updating contact details")
log.info("Updated contact details:\n{0}".format("\n".join(response['contact'])))
# https://datatracker.ietf.org/doc/html/rfc8555#section-7.3.3 : #userActionRequired only for TOS in RFC8555
if code == 403 and response['type'] == 'urn:ietf:params:acme:error:userActionRequired':
log.info("You must agree to updated TOS:\n", response['instance'])

# create a new order
log.info("Creating new order...")
Expand Down Expand Up @@ -171,7 +190,7 @@ def main(argv=None):
description=textwrap.dedent("""\
This script automates the process of getting a signed TLS certificate from Let's Encrypt using
the ACME protocol. It will need to be run on your server and have access to your private
account key, so PLEASE READ THROUGH IT! It's only ~200 lines, so it won't take long.
account key, so PLEASE READ THROUGH IT! It's only ~220 lines, so it won't take long.
Example Usage:
python acme_tiny.py --account-key ./account.key --csr ./domain.csr --acme-dir /usr/share/nginx/html/.well-known/acme-challenge/ > signed_chain.crt
Expand All @@ -188,10 +207,12 @@ def main(argv=None):
parser.add_argument("--directory-url", default=DEFAULT_DIRECTORY_URL, help="certificate authority directory url, default is Let's Encrypt")
parser.add_argument("--ca", default=DEFAULT_CA, help="DEPRECATED! USE --directory-url INSTEAD!")
parser.add_argument("--contact", metavar="CONTACT", default=None, nargs="*", help="Contact details (e.g. mailto:[email protected]) for your account-key")
parser.add_argument("--eabkid", metavar="KID", default=None, help="Key Identifier for External Account Binding")
parser.add_argument("--eabhmackey", metavar="HMAC", default=None, help="HMAC key for External Account Binding")

args = parser.parse_args(argv)
LOGGER.setLevel(args.quiet or LOGGER.level)
signed_crt = get_crt(args.account_key, args.csr, args.acme_dir, log=LOGGER, CA=args.ca, disable_check=args.disable_check, directory_url=args.directory_url, contact=args.contact)
signed_crt = get_crt(args.account_key, args.csr, args.acme_dir, log=LOGGER, CA=args.ca, disable_check=args.disable_check, directory_url=args.directory_url, contact=args.contact, eabkid=args.eabkid, eabhmackey=args.eabhmackey)
sys.stdout.write(signed_crt)

if __name__ == "__main__": # pragma: no cover
Expand Down

0 comments on commit e0df39f

Please sign in to comment.