def retrieve_acme_v1_certificate(client, csr_der): ''' Create a new certificate based on the CSR (ACME v1 protocol). Return the certificate object as dict https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.5 ''' new_cert = { "resource": "new-cert", "csr": nopad_b64(csr_der), } result, info = client.send_signed_request( client.directory['new-cert'], new_cert, error_msg='Failed to receive certificate', expected_status_codes=[200, 201]) cert = CertificateChain(info['location']) cert.cert = der_to_pem(result) def f(link, relation): if relation == 'up': chain_result, chain_info = client.get_request( link, parse_json_result=False) if chain_info['status'] in [200, 201]: del cert.chain[:] cert.chain.append(der_to_pem(chain_result)) process_links(info, f) return cert
def finalize(self, client, csr_der, wait=True): ''' Create a new certificate based on the csr. Return the certificate object as dict https://tools.ietf.org/html/rfc8555#section-7.4 ''' new_cert = { "csr": nopad_b64(csr_der), } result, info = client.send_signed_request( self.finalize_uri, new_cert, error_msg='Failed to finalizing order', expected_status_codes=[200]) # It is not clear from the RFC whether the finalize call returns the order object or not. # Instead of using the result, we call self.refresh(client) below. if wait: self.wait_for_finalization(client) else: self.refresh(client) if self.status not in ['procesing', 'valid', 'invalid']: raise ACMEProtocolException( client.module, 'Failed to finalize order; got status "{status}"'.format( status=self.status), info=info, content_json=result)
def create_key_authorization(client, token): ''' Returns the key authorization for the given token https://tools.ietf.org/html/rfc8555#section-8.1 ''' accountkey_json = json.dumps(client.account_jwk, sort_keys=True, separators=(',', ':')) thumbprint = nopad_b64(hashlib.sha256(accountkey_json.encode('utf8')).digest()) return "{0}.{1}".format(token, thumbprint)
def sign_request(self, protected, payload, key_data, encode_payload=True): ''' Signs an ACME request. ''' try: if payload is None: # POST-as-GET payload64 = '' else: # POST if encode_payload: payload = self.module.jsonify(payload).encode('utf8') payload64 = nopad_b64(to_bytes(payload)) protected64 = nopad_b64( self.module.jsonify(protected).encode('utf8')) except Exception as e: raise ModuleFailException( "Failed to encode payload / headers as JSON: {0}".format(e)) return self.backend.sign(payload64, protected64, key_data)
def get_validation_data(self, client, identifier_type, identifier): token = re.sub(r"[^A-Za-z0-9_\-]", "_", self.token) key_authorization = create_key_authorization(client, token) if self.type == 'http-01': # https://tools.ietf.org/html/rfc8555#section-8.3 return { 'resource': '.well-known/acme-challenge/{token}'.format(token=token), 'resource_value': key_authorization, } if self.type == 'dns-01': if identifier_type != 'dns': return None # https://tools.ietf.org/html/rfc8555#section-8.4 resource = '_acme-challenge' value = nopad_b64( hashlib.sha256(to_bytes(key_authorization)).digest()) record = (resource + identifier[1:]) if identifier.startswith( '*.') else '{0}.{1}'.format(resource, identifier) return { 'resource': resource, 'resource_value': value, 'record': record, } if self.type == 'tls-alpn-01': # https://www.rfc-editor.org/rfc/rfc8737.html#section-3 if identifier_type == 'ip': # IPv4/IPv6 address: use reverse mapping (RFC1034, RFC3596) resource = compat_ipaddress.ip_address( identifier).reverse_pointer if not resource.endswith('.'): resource += '.' else: resource = identifier value = base64.b64encode( hashlib.sha256(to_bytes(key_authorization)).digest()) return { 'resource': resource, 'resource_original': combine_identifier(identifier_type, identifier), 'resource_value': value, } # Unknown challenge type: ignore return None
def sign(self, payload64, protected64, key_data): sign_payload = "{0}.{1}".format(protected64, payload64).encode('utf8') if key_data['type'] == 'hmac': hex_key = to_native( binascii.hexlify(base64.urlsafe_b64decode( key_data['jwk']['k']))) cmd_postfix = [ "-mac", "hmac", "-macopt", "hexkey:{0}".format(hex_key), "-binary" ] else: cmd_postfix = ["-sign", key_data['key_file']] openssl_sign_cmd = [ self.openssl_binary, "dgst", "-{0}".format(key_data['hash']) ] + cmd_postfix dummy, out, dummy = self.module.run_command( openssl_sign_cmd, data=sign_payload, check_rc=True, binary_data=True, environ_update=_OPENSSL_ENVIRONMENT_UPDATE) if key_data['type'] == 'ec': dummy, der_out, dummy = self.module.run_command( [self.openssl_binary, "asn1parse", "-inform", "DER"], data=out, binary_data=True, environ_update=_OPENSSL_ENVIRONMENT_UPDATE) expected_len = 2 * key_data['point_size'] sig = re.findall( r"prim:\s+INTEGER\s+:([0-9A-F]{1,%s})\n" % expected_len, to_text(der_out, errors='surrogate_or_strict')) if len(sig) != 2: raise BackendException( "failed to generate Elliptic Curve signature; cannot parse DER output: {0}" .format(to_text(der_out, errors='surrogate_or_strict'))) sig[0] = (expected_len - len(sig[0])) * '0' + sig[0] sig[1] = (expected_len - len(sig[1])) * '0' + sig[1] out = binascii.unhexlify(sig[0]) + binascii.unhexlify(sig[1]) return { "protected": protected64, "payload": payload64, "signature": nopad_b64(to_bytes(out)), }
def sign(self, payload64, protected64, key_data): sign_payload = "{0}.{1}".format(protected64, payload64).encode('utf8') if 'mac_obj' in key_data: mac = key_data['mac_obj']() mac.update(sign_payload) signature = mac.finalize() elif isinstance( key_data['key_obj'], cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey): padding = cryptography.hazmat.primitives.asymmetric.padding.PKCS1v15( ) hashalg = cryptography.hazmat.primitives.hashes.SHA256 signature = key_data['key_obj'].sign(sign_payload, padding, hashalg()) elif isinstance( key_data['key_obj'], cryptography.hazmat.primitives.asymmetric. ec.EllipticCurvePrivateKey): if key_data['hash'] == 'sha256': hashalg = cryptography.hazmat.primitives.hashes.SHA256 elif key_data['hash'] == 'sha384': hashalg = cryptography.hazmat.primitives.hashes.SHA384 elif key_data['hash'] == 'sha512': hashalg = cryptography.hazmat.primitives.hashes.SHA512 ecdsa = cryptography.hazmat.primitives.asymmetric.ec.ECDSA( hashalg()) r, s = cryptography.hazmat.primitives.asymmetric.utils.decode_dss_signature( key_data['key_obj'].sign(sign_payload, ecdsa)) rr = _pad_hex(r, 2 * key_data['point_size']) ss = _pad_hex(s, 2 * key_data['point_size']) signature = binascii.unhexlify(rr) + binascii.unhexlify(ss) return { "protected": protected64, "payload": payload64, "signature": nopad_b64(signature), }
def parse_key(self, key_file=None, key_content=None, passphrase=None): ''' Parses an RSA or Elliptic Curve key file in PEM format and returns key_data. Raises KeyParsingError in case of errors. ''' # If key_content isn't given, read key_file if key_content is None: key_content = read_file(key_file) else: key_content = to_bytes(key_content) # Parse key try: key = cryptography.hazmat.primitives.serialization.load_pem_private_key( key_content, password=to_bytes(passphrase) if passphrase is not None else None, backend=_cryptography_backend) except Exception as e: raise KeyParsingError('error while loading key: {0}'.format(e)) if isinstance( key, cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey): pk = key.public_key().public_numbers() return { 'key_obj': key, 'type': 'rsa', 'alg': 'RS256', 'jwk': { "kty": "RSA", "e": nopad_b64(_convert_int_to_bytes(_count_bytes(pk.e), pk.e)), "n": nopad_b64(_convert_int_to_bytes(_count_bytes(pk.n), pk.n)), }, 'hash': 'sha256', } elif isinstance( key, cryptography.hazmat.primitives.asymmetric.ec. EllipticCurvePrivateKey): pk = key.public_key().public_numbers() if pk.curve.name == 'secp256r1': bits = 256 alg = 'ES256' hashalg = 'sha256' point_size = 32 curve = 'P-256' elif pk.curve.name == 'secp384r1': bits = 384 alg = 'ES384' hashalg = 'sha384' point_size = 48 curve = 'P-384' elif pk.curve.name == 'secp521r1': # Not yet supported on Let's Encrypt side, see # https://github.com/letsencrypt/boulder/issues/2217 bits = 521 alg = 'ES512' hashalg = 'sha512' point_size = 66 curve = 'P-521' else: raise KeyParsingError('unknown elliptic curve: {0}'.format( pk.curve.name)) num_bytes = (bits + 7) // 8 return { 'key_obj': key, 'type': 'ec', 'alg': alg, 'jwk': { "kty": "EC", "crv": curve, "x": nopad_b64(_convert_int_to_bytes(num_bytes, pk.x)), "y": nopad_b64(_convert_int_to_bytes(num_bytes, pk.y)), }, 'hash': hashalg, 'point_size': point_size, } else: raise KeyParsingError('unknown key type "{0}"'.format(type(key)))
def main(): argument_spec = get_default_argspec() argument_spec.update( dict( private_key_src=dict(type='path'), private_key_content=dict(type='str', no_log=True), private_key_passphrase=dict(type='str', no_log=True), certificate=dict(type='path', required=True), revoke_reason=dict(type='int'), )) module = AnsibleModule( argument_spec=argument_spec, required_one_of=([ 'account_key_src', 'account_key_content', 'private_key_src', 'private_key_content' ], ), mutually_exclusive=([ 'account_key_src', 'account_key_content', 'private_key_src', 'private_key_content' ], ), supports_check_mode=False, ) backend = create_backend(module, False) try: client = ACMEClient(module, backend) account = ACMEAccount(client) # Load certificate certificate = pem_to_der(module.params.get('certificate')) certificate = nopad_b64(certificate) # Construct payload payload = {'certificate': certificate} if module.params.get('revoke_reason') is not None: payload['reason'] = module.params.get('revoke_reason') # Determine endpoint if module.params.get('acme_version') == 1: endpoint = client.directory['revoke-cert'] payload['resource'] = 'revoke-cert' else: endpoint = client.directory['revokeCert'] # Get hold of private key (if available) and make sure it comes from disk private_key = module.params.get('private_key_src') private_key_content = module.params.get('private_key_content') # Revoke certificate if private_key or private_key_content: passphrase = module.params['private_key_passphrase'] # Step 1: load and parse private key try: private_key_data = client.parse_key(private_key, private_key_content, passphrase=passphrase) except KeyParsingError as e: raise ModuleFailException( "Error while parsing private key: {msg}".format(msg=e.msg)) # Step 2: sign revokation request with private key jws_header = { "alg": private_key_data['alg'], "jwk": private_key_data['jwk'], } result, info = client.send_signed_request( endpoint, payload, key_data=private_key_data, jws_header=jws_header, fail_on_error=False) else: # Step 1: get hold of account URI created, account_data = account.setup_account(allow_creation=False) if created: raise AssertionError('Unwanted account creation') if account_data is None: raise ModuleFailException( msg='Account does not exist or is deactivated.') # Step 2: sign revokation request with account key result, info = client.send_signed_request(endpoint, payload, fail_on_error=False) if info['status'] != 200: already_revoked = False # Standardized error from draft 14 on (https://tools.ietf.org/html/rfc8555#section-7.6) if result.get( 'type') == 'urn:ietf:params:acme:error:alreadyRevoked': already_revoked = True else: # Hack for Boulder errors if module.params.get('acme_version') == 1: error_type = 'urn:acme:error:malformed' else: error_type = 'urn:ietf:params:acme:error:malformed' if result.get('type') == error_type and result.get( 'detail') == 'Certificate already revoked': # Fallback: boulder returns this in case the certificate was already revoked. already_revoked = True # If we know the certificate was already revoked, we don't fail, # but successfully terminate while indicating no change if already_revoked: module.exit_json(changed=False) raise ACMEProtocolException('Failed to revoke certificate', info=info, content_json=result) module.exit_json(changed=True) except ModuleFailException as e: e.do_fail(module)
def parse_key(self, key_file=None, key_content=None, passphrase=None): ''' Parses an RSA or Elliptic Curve key file in PEM format and returns key_data. Raises KeyParsingError in case of errors. ''' if passphrase is not None: raise KeyParsingError( 'openssl backend does not support key passphrases') # If key_file isn't given, but key_content, write that to a temporary file if key_file is None: fd, tmpsrc = tempfile.mkstemp() self.module.add_cleanup_file( tmpsrc) # Ansible will delete the file on exit f = os.fdopen(fd, 'wb') try: f.write(key_content.encode('utf-8')) key_file = tmpsrc except Exception as err: try: f.close() except Exception as dummy: pass raise KeyParsingError( "failed to create temporary content file: %s" % to_native(err), exception=traceback.format_exc()) f.close() # Parse key account_key_type = None with open(key_file, "rt") as f: for line in f: m = re.match( r"^\s*-{5,}BEGIN\s+(EC|RSA)\s+PRIVATE\s+KEY-{5,}\s*$", line) if m is not None: account_key_type = m.group(1).lower() break if account_key_type is None: # This happens for example if openssl_privatekey created this key # (as opposed to the OpenSSL binary). For now, we assume this is # an RSA key. # FIXME: add some kind of auto-detection account_key_type = "rsa" if account_key_type not in ("rsa", "ec"): raise KeyParsingError('unknown key type "%s"' % account_key_type) openssl_keydump_cmd = [ self.openssl_binary, account_key_type, "-in", key_file, "-noout", "-text" ] dummy, out, dummy = self.module.run_command( openssl_keydump_cmd, check_rc=True, environ_update=_OPENSSL_ENVIRONMENT_UPDATE) if account_key_type == 'rsa': pub_hex, pub_exp = re.search( r"modulus:\n\s+00:([a-f0-9\:\s]+?)\npublicExponent: ([0-9]+)", to_text(out, errors='surrogate_or_strict'), re.MULTILINE | re.DOTALL).groups() pub_exp = "{0:x}".format(int(pub_exp)) if len(pub_exp) % 2: pub_exp = "0{0}".format(pub_exp) return { 'key_file': key_file, 'type': 'rsa', 'alg': 'RS256', 'jwk': { "kty": "RSA", "e": nopad_b64(binascii.unhexlify(pub_exp.encode("utf-8"))), "n": nopad_b64( binascii.unhexlify( re.sub(r"(\s|:)", "", pub_hex).encode("utf-8"))), }, 'hash': 'sha256', } elif account_key_type == 'ec': pub_data = re.search( r"pub:\s*\n\s+04:([a-f0-9\:\s]+?)\nASN1 OID: (\S+)(?:\nNIST CURVE: (\S+))?", to_text(out, errors='surrogate_or_strict'), re.MULTILINE | re.DOTALL) if pub_data is None: raise KeyParsingError('cannot parse elliptic curve key') pub_hex = binascii.unhexlify( re.sub(r"(\s|:)", "", pub_data.group(1)).encode("utf-8")) asn1_oid_curve = pub_data.group(2).lower() nist_curve = pub_data.group(3).lower() if pub_data.group( 3) else None if asn1_oid_curve == 'prime256v1' or nist_curve == 'p-256': bits = 256 alg = 'ES256' hashalg = 'sha256' point_size = 32 curve = 'P-256' elif asn1_oid_curve == 'secp384r1' or nist_curve == 'p-384': bits = 384 alg = 'ES384' hashalg = 'sha384' point_size = 48 curve = 'P-384' elif asn1_oid_curve == 'secp521r1' or nist_curve == 'p-521': # Not yet supported on Let's Encrypt side, see # https://github.com/letsencrypt/boulder/issues/2217 bits = 521 alg = 'ES512' hashalg = 'sha512' point_size = 66 curve = 'P-521' else: raise KeyParsingError('unknown elliptic curve: %s / %s' % (asn1_oid_curve, nist_curve)) num_bytes = (bits + 7) // 8 if len(pub_hex) != 2 * num_bytes: raise KeyParsingError('bad elliptic curve point (%s / %s)' % (asn1_oid_curve, nist_curve)) return { 'key_file': key_file, 'type': 'ec', 'alg': alg, 'jwk': { "kty": "EC", "crv": curve, "x": nopad_b64(pub_hex[:num_bytes]), "y": nopad_b64(pub_hex[num_bytes:]), }, 'hash': hashalg, 'point_size': point_size, }
def test_nopad_b64(value, result): assert nopad_b64(value.encode('utf-8')) == result