Skip to content

Commit f15c705

Browse files
committed
Implement externalAccountBinding
https://datatracker.ietf.org/doc/html/rfc8555#section-7.3.4 Renamed `account` variable to the more appropriate: `response`. That is what this variable holds.
1 parent ce0bbdb commit f15c705

File tree

2 files changed

+39
-8
lines changed

2 files changed

+39
-8
lines changed

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,21 @@ and read your private account key and CSR.
122122
python acme_tiny.py --account-key ./account.key --csr ./domain.csr --acme-dir /var/www/challenges/ > ./signed_chain.crt
123123
```
124124

125+
If your ACME CA mandates externalAccountBinding (eAB), provide those parameters like so:
126+
127+
```
128+
# Run the script on your server
129+
python acme_tiny.py --account-key ./account.key --csr ./domain.csr --acme-dir /var/www/challenges/ --eabkid 'PAtzxcSFQMQSdm9SLJTxCt0hwvvl5yNKPfnWBWqPk8o' --eabhmackey 'ZndUSkZvVldvMEFiRzQ5VWNCdERtNkNBNnBTcTl4czNKVEVxdUZiaEdpZXZNUVJBVmRuSFREcDJYX2s3X0NxTA' > ./signed_chain.crt
130+
```
131+
132+
Some ACME CA mandate a contact at registration:
133+
134+
```
135+
# Run the script on your server
136+
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
137+
```
138+
139+
125140
### Step 5: Install the certificate
126141

127142
The signed https certificate chain that is output by this script can be used along

acme_tiny.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env python
22
# Copyright Daniel Roesler, under MIT license, see LICENSE at github.com/diafygi/acme-tiny
3-
import argparse, subprocess, json, os, sys, base64, binascii, time, hashlib, re, copy, textwrap, logging
3+
import argparse, subprocess, json, os, sys, base64, binascii, time, hashlib, re, copy, textwrap, logging, hmac
44
try:
55
from urllib.request import urlopen, Request # Python 3
66
except ImportError: # pragma: no cover
@@ -13,7 +13,7 @@
1313
LOGGER.addHandler(logging.StreamHandler())
1414
LOGGER.setLevel(logging.INFO)
1515

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

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

73+
# helper function - build the eAB: externalAccountBinding
74+
def _build_eab(url, eabkid, eabhmackey, jwk):
75+
try: # Decode to verify HMAC b64 is good. Pad b64 string with '='. Py3 strips extra pad.
76+
eabhmackey = base64.urlsafe_b64decode(eabhmackey.strip() + '==') # hmac broken anyway if len%4 != 0
77+
except (binascii.Error, TypeError): # T-E = py2 (incorrect b64 padding)
78+
log.info("Error verifying eAB HMAC.")
79+
protected64 = _b64(json.dumps({"alg": "HS256", "kid": eabkid, "url": url}).encode('utf-8'))
80+
payload64 = _b64(json.dumps(jwk).encode('utf-8'))
81+
signed64 = _b64(hmac.new(eabhmackey, (protected64 + "." + payload64).encode('utf-8'), digestmod=hashlib.sha256).digest())
82+
return {"protected": protected64, "payload": payload64, "signature": signed64}
83+
7384
# parse account key to get public key
7485
log.info("Parsing account key...")
7586
out = _cmd(["openssl", "rsa", "-in", account_key, "-noout", "-text"], err_msg="OpenSSL Error")
@@ -108,11 +119,14 @@ def _poll_until_not(url, pending_statuses, err_msg):
108119
# create account, update contact details (if any), and set the global key identifier
109120
log.info("Registering account...")
110121
reg_payload = {"termsOfServiceAgreed": True} if contact is None else {"termsOfServiceAgreed": True, "contact": contact}
111-
account, code, acct_headers = _send_signed_request(directory['newAccount'], reg_payload, "Error registering")
122+
if eabkid and eabhmackey: # https://datatracker.ietf.org/doc/html/rfc8555#section-7.3.4
123+
log.info("Building externalAccountBinding...")
124+
reg_payload['externalAccountBinding'] = _build_eab(directory['newAccount'], eabkid, eabhmackey, jwk)
125+
response, code, acct_headers = _send_signed_request(directory['newAccount'], reg_payload, "Error registering")
112126
log.info("{0} Account ID: {1}".format("Registered!" if code == 201 else "Already registered!", acct_headers['Location']))
113-
if contact is not None:
114-
account, _, _ = _send_signed_request(acct_headers['Location'], {"contact": contact}, "Error updating contact details")
115-
log.info("Updated contact details:\n{0}".format("\n".join(account['contact'])))
127+
if contact and code == 200: # 200 == already reg --> update
128+
response, _, _ = _send_signed_request(acct_headers['Location'], {"contact": contact}, "Error updating contact details")
129+
log.info("Updated contact details:\n{0}".format("\n".join(response['contact'])))
116130

117131
# create a new order
118132
log.info("Creating new order...")
@@ -175,7 +189,7 @@ def main(argv=None):
175189
description=textwrap.dedent("""\
176190
This script automates the process of getting a signed TLS certificate from Let's Encrypt using the ACME protocol.
177191
It will need to be run on your server and have access to your private account key, so PLEASE READ THROUGH IT!
178-
It's only ~200 lines, so it won't take long.
192+
It's only ~220 lines, so it won't take long.
179193
180194
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
181195
""")
@@ -189,10 +203,12 @@ def main(argv=None):
189203
parser.add_argument("--ca", default=DEFAULT_CA, help="DEPRECATED! USE --directory-url INSTEAD!")
190204
parser.add_argument("--contact", metavar="CONTACT", default=None, nargs="*", help="Contact details (e.g. mailto:[email protected]) for your account-key")
191205
parser.add_argument("--check-port", metavar="PORT", default=None, help="what port to use when self-checking the challenge file, default is port 80")
206+
parser.add_argument("--eabkid", metavar="KID", default=None, help="Key Identifier for External Account Binding")
207+
parser.add_argument("--eabhmackey", metavar="HMAC", default=None, help="HMAC key for External Account Binding")
192208

193209
args = parser.parse_args(argv)
194210
LOGGER.setLevel(args.quiet or LOGGER.level)
195-
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, check_port=args.check_port)
211+
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, check_port=args.check_port, eabkid=args.eabkid, eabhmackey=args.eabhmackey)
196212
sys.stdout.write(signed_crt)
197213

198214
if __name__ == "__main__": # pragma: no cover

0 commit comments

Comments
 (0)