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
44try :
55 from urllib .request import urlopen , Request # Python 3
66except ImportError : # pragma: no cover
1313LOGGER .addHandler (logging .StreamHandler ())
1414LOGGER .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
198214if __name__ == "__main__" : # pragma: no cover
0 commit comments