URL: https://github.com/freeipa/freeipa/pull/542 Author: LiptonB Title: #542: Implementation independent interface for CSR generation Action: synchronized
To pull the PR as Git branch: git remote add ghfreeipa https://github.com/freeipa/freeipa git fetch ghfreeipa pull/542/head:pr542 git checkout pr542
From 6f678a1c68769daf5b2f80cbf65a5b6b1c99f7a1 Mon Sep 17 00:00:00 2001 From: Ben Lipton <blip...@redhat.com> Date: Fri, 7 Oct 2016 10:39:40 -0400 Subject: [PATCH 1/3] csrgen: Add "certmonger" helper to produce simplified openssl configs The resulting file is similar to the config accepted by `openssl req`, but instead of a section for the subject name, it contains a single config item, `cm_template_subject`, in the format that certmonger accepts for its certificate subject names. https://pagure.io/freeipa/issue/4899 --- install/share/csrgen/Makefile.am | 1 + install/share/csrgen/rules/dataDNS.json | 2 +- install/share/csrgen/rules/dataEmail.json | 2 +- install/share/csrgen/rules/dataHostCN.json | 2 +- install/share/csrgen/rules/dataSubjectBase.json | 2 +- install/share/csrgen/rules/dataUsernameCN.json | 2 +- install/share/csrgen/rules/syntaxSAN.json | 2 +- install/share/csrgen/rules/syntaxSubject.json | 4 ++++ install/share/csrgen/templates/certmonger_base.tmpl | 17 +++++++++++++++++ ipaclient/csrgen.py | 7 ++++++- 10 files changed, 34 insertions(+), 7 deletions(-) create mode 100644 install/share/csrgen/templates/certmonger_base.tmpl diff --git a/install/share/csrgen/Makefile.am b/install/share/csrgen/Makefile.am index 12c62c4..53324fe 100644 --- a/install/share/csrgen/Makefile.am +++ b/install/share/csrgen/Makefile.am @@ -20,6 +20,7 @@ rule_DATA = \ templatedir = $(IPA_DATA_DIR)/csrgen/templates template_DATA = \ templates/certutil_base.tmpl \ + templates/certmonger_base.tmpl \ templates/openssl_base.tmpl \ templates/openssl_macros.tmpl \ $(NULL) diff --git a/install/share/csrgen/rules/dataDNS.json b/install/share/csrgen/rules/dataDNS.json index 2663f11..263eae0 100644 --- a/install/share/csrgen/rules/dataDNS.json +++ b/install/share/csrgen/rules/dataDNS.json @@ -1,7 +1,7 @@ { "rules": [ { - "helper": "openssl", + "helper": ["openssl", "certmonger"], "template": "DNS = {{subject.krbprincipalname.0.partition('/')[2].partition('@')[0]}}" }, { diff --git a/install/share/csrgen/rules/dataEmail.json b/install/share/csrgen/rules/dataEmail.json index 2eae9fb..5c4dbe8 100644 --- a/install/share/csrgen/rules/dataEmail.json +++ b/install/share/csrgen/rules/dataEmail.json @@ -1,7 +1,7 @@ { "rules": [ { - "helper": "openssl", + "helper": ["openssl", "certmonger"], "template": "email = {{subject.mail.0}}" }, { diff --git a/install/share/csrgen/rules/dataHostCN.json b/install/share/csrgen/rules/dataHostCN.json index 5c415bb..8c17e8a 100644 --- a/install/share/csrgen/rules/dataHostCN.json +++ b/install/share/csrgen/rules/dataHostCN.json @@ -1,7 +1,7 @@ { "rules": [ { - "helper": "openssl", + "helper": ["openssl", "certmonger"], "template": "CN={{subject.krbprincipalname.0.partition('/')[2].partition('@')[0]}}" }, { diff --git a/install/share/csrgen/rules/dataSubjectBase.json b/install/share/csrgen/rules/dataSubjectBase.json index 309dfb1..489dd49 100644 --- a/install/share/csrgen/rules/dataSubjectBase.json +++ b/install/share/csrgen/rules/dataSubjectBase.json @@ -1,7 +1,7 @@ { "rules": [ { - "helper": "openssl", + "helper": ["openssl", "certmonger"], "template": "{{config.ipacertificatesubjectbase.0}}" }, { diff --git a/install/share/csrgen/rules/dataUsernameCN.json b/install/share/csrgen/rules/dataUsernameCN.json index 37e7e01..d294ab2 100644 --- a/install/share/csrgen/rules/dataUsernameCN.json +++ b/install/share/csrgen/rules/dataUsernameCN.json @@ -1,7 +1,7 @@ { "rules": [ { - "helper": "openssl", + "helper": ["openssl", "certmonger"], "template": "CN={{subject.uid.0}}" }, { diff --git a/install/share/csrgen/rules/syntaxSAN.json b/install/share/csrgen/rules/syntaxSAN.json index 122eb12..00b1808 100644 --- a/install/share/csrgen/rules/syntaxSAN.json +++ b/install/share/csrgen/rules/syntaxSAN.json @@ -1,7 +1,7 @@ { "rules": [ { - "helper": "openssl", + "helper": ["openssl", "certmonger"], "template": "subjectAltName = @{% call openssl.section() %}{{ datarules|join('\n') }}{% endcall %}", "options": { "extension": true diff --git a/install/share/csrgen/rules/syntaxSubject.json b/install/share/csrgen/rules/syntaxSubject.json index af6ec03..1eb02e9 100644 --- a/install/share/csrgen/rules/syntaxSubject.json +++ b/install/share/csrgen/rules/syntaxSubject.json @@ -5,6 +5,10 @@ "template": "distinguished_name = {% call openssl.section() %}{{ datarules|reverse|join('\n') }}{% endcall %}" }, { + "helper": "certmonger", + "template": "cm_template_subject = {{ datarules|reverse|join(',') }}" + }, + { "helper": "certutil", "template": "-s {{ datarules|join(',') }}" } diff --git a/install/share/csrgen/templates/certmonger_base.tmpl b/install/share/csrgen/templates/certmonger_base.tmpl new file mode 100644 index 0000000..2a695a7 --- /dev/null +++ b/install/share/csrgen/templates/certmonger_base.tmpl @@ -0,0 +1,17 @@ +{% raw -%} +{% import "openssl_macros.tmpl" as openssl -%} +{%- endraw %} +[ req ] +prompt = no +encrypt_key = no + +{{ parameters|join('\n') }} +{% raw %}{% set rendered_extensions -%}{% endraw %} +{{ extensions|join('\n') }} +{% raw -%} +{%- endset -%} +{% if rendered_extensions -%} +req_extensions = {% call openssl.section() %}{{ rendered_extensions }}{% endcall %} +{% endif %} +{{ openssl.openssl_sections|join('\n\n') }} +{%- endraw %} diff --git a/ipaclient/csrgen.py b/ipaclient/csrgen.py index 96100ae..3fb944b 100644 --- a/ipaclient/csrgen.py +++ b/ipaclient/csrgen.py @@ -228,6 +228,10 @@ def _prepare_syntax_rule( return self.SyntaxRule(prepared_template, is_extension) +class CertmongerFormatter(OpenSSLFormatter): + base_template_name = 'certmonger_base.tmpl' + + class CertutilFormatter(Formatter): base_template_name = 'certutil_base.tmpl' @@ -294,7 +298,7 @@ def _rule(self, rule_name, helper): {'ruleset': rule_name}) matching_rules = [r for r in ruleset['rules'] - if r['helper'] == helper] + if helper in r['helper']] if len(matching_rules) == 0: raise errors.EmptyResult( reason=_('No transformation in "%(ruleset)s" rule supports' @@ -340,6 +344,7 @@ class CSRGenerator(object): FORMATTERS = { 'openssl': OpenSSLFormatter, 'certutil': CertutilFormatter, + 'certmonger': CertmongerFormatter, } def __init__(self, rule_provider): From 88094f6a2d2fe4b34715b25a4c17de6001b9edc2 Mon Sep 17 00:00:00 2001 From: Ben Lipton <blip...@redhat.com> Date: Fri, 6 Jan 2017 11:19:19 -0500 Subject: [PATCH 2/3] csrgen: Modify cert_get_requestdata to return a CertificationRequestInfo Also modify cert_request to use this new format. Note, only PEM private keys are supported for now. NSS databases are not. https://pagure.io/freeipa/issue/4899 --- ipaclient/csrgen.py | 97 ++++++++++++++++++++++++++++++++++++++++++++- ipaclient/plugins/cert.py | 75 +++++++++++++++-------------------- ipaclient/plugins/csrgen.py | 32 +++++++-------- 3 files changed, 143 insertions(+), 61 deletions(-) diff --git a/ipaclient/csrgen.py b/ipaclient/csrgen.py index 3fb944b..9033eb2 100644 --- a/ipaclient/csrgen.py +++ b/ipaclient/csrgen.py @@ -6,11 +6,21 @@ import json import os.path import pipes +import subprocess +from tempfile import NamedTemporaryFile as NTF import traceback +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.serialization import ( + load_pem_private_key, Encoding, PublicFormat) import jinja2 import jinja2.ext import jinja2.sandbox +from pyasn1.codec.der import decoder, encoder +from pyasn1.type import univ +from pyasn1_modules import rfc2314 import six from ipalib import errors @@ -53,7 +63,8 @@ def quote(self, data): def required(self, data, name): if not data: raise errors.CSRTemplateError( - reason=_('Required CSR generation rule %(name)s is missing data') % + reason=_( + 'Required CSR generation rule %(name)s is missing data') % {'name': name}) return data @@ -365,3 +376,87 @@ def csr_script(self, principal, config, profile_id, helper): 'Template error when formatting certificate data')) return script + + +def build_requestinfo(csr_data, public_key_info): + if len(public_key_info) > 64 and '\n' not in public_key_info: + # build_requestinfo needs its base64 split into 64-character lines + # otherwise parsing fails + public_key_info = '\r\n'.join( + [public_key_info[x:x+64] + for x in range(0, len(public_key_info), 64)]) + + with NTF() as config_file, NTF() as pubkey_file: + config_file.write(csr_data) + config_file.flush() + pubkey_file.write(public_key_info) + pubkey_file.flush() + + # TODO(blipton): Build and install this binary + request_info = subprocess.check_output( + ['build_requestinfo', config_file.name, pubkey_file.name]) + + return request_info + + +class CSRLibraryAdaptor(object): + def get_subject_public_key_info(self): + raise NotImplementedError('Use a subclass of CSRLibraryAdaptor') + + def sign_csr(self, certification_request_info): + """Sign a CertificationRequestInfo. + + Returns: str, a DER-encoded signed CSR. + """ + raise NotImplementedError('Use a subclass of CSRLibraryAdaptor') + + +class OpenSSLAdaptor(object): + def __init__(self, key_filename, password_filename): + self.key_filename = key_filename + self.password_filename = password_filename + + def key(self): + with open(self.key_filename, 'r') as key_file: + key_bytes = key_file.read() + password = None + if self.password_filename is not None: + with open(self.password_filename, 'r') as password_file: + password = password_file.read().strip() + + key = load_pem_private_key(key_bytes, password, default_backend()) + return key + + def get_subject_public_key_info(self): + pubkey_info = self.key().public_key().public_bytes( + Encoding.DER, PublicFormat.SubjectPublicKeyInfo) + return pubkey_info + + def sign_csr(self, certification_request_info): + reqinfo = decoder.decode( + certification_request_info, rfc2314.CertificationRequestInfo())[0] + csr = rfc2314.CertificationRequest() + csr.setComponentByName('certificationRequestInfo', reqinfo) + + algorithm = rfc2314.SignatureAlgorithmIdentifier() + algorithm.setComponentByName( + 'algorithm', univ.ObjectIdentifier( + '1.2.840.113549.1.1.11')) # sha256WithRSAEncryption + csr.setComponentByName('signatureAlgorithm', algorithm) + + signature = self.key().sign( + certification_request_info, + padding.PKCS1v15(), + hashes.SHA256() + ) + asn1sig = univ.BitString("'%s'H" % signature.encode('hex')) + csr.setComponentByName('signature', asn1sig) + return encoder.encode(csr) + + +class NSSAdaptor(object): + def get_subject_public_key_info(self): + raise NotImplementedError('NSS is not yet supported') + + def sign_csr(self, certification_request_info): + raise NotImplementedError('NSS is not yet supported') diff --git a/ipaclient/plugins/cert.py b/ipaclient/plugins/cert.py index 348529c..ad73ce7 100644 --- a/ipaclient/plugins/cert.py +++ b/ipaclient/plugins/cert.py @@ -19,11 +19,12 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. +import base64 import subprocess -from tempfile import NamedTemporaryFile as NTF import six +from ipaclient import csrgen from ipaclient.frontend import MethodOverride from ipalib import errors from ipalib import x509 @@ -77,54 +78,40 @@ def forward(self, csr=None, **options): if csr is None: if database: - helper = u'certutil' - helper_args = ['-d', database] - if password_file: - helper_args += ['-f', password_file] + adaptor = csrgen.NSSAdaptor(database, password_file) elif private_key: - helper = u'openssl' - helper_args = [private_key] - if password_file: - helper_args += ['-passin', 'file:%s' % password_file] + adaptor = csrgen.OpenSSLAdaptor(private_key, password_file) else: raise errors.InvocationError( message=u"One of 'database' or 'private_key' is required") - with NTF() as scriptfile, NTF() as csrfile: - # If csr_profile_id is passed, that takes precedence. - # Otherwise, use profile_id. If neither are passed, the default - # in cert_get_requestdata will be used. - profile_id = csr_profile_id - if profile_id is None: - profile_id = options.get('profile_id') - - self.api.Command.cert_get_requestdata( - profile_id=profile_id, - principal=options.get('principal'), - out=unicode(scriptfile.name), - helper=helper) - - helper_cmd = [ - 'bash', '-e', scriptfile.name, csrfile.name] + helper_args - - try: - subprocess.check_output(helper_cmd) - except subprocess.CalledProcessError as e: - raise errors.CertificateOperationError( - error=( - _('Error running "%(cmd)s" to generate CSR:' - ' %(err)s') % - {'cmd': ' '.join(helper_cmd), 'err': e.output})) - - try: - csr = unicode(csrfile.read()) - except IOError as e: - raise errors.CertificateOperationError( - error=(_('Unable to read generated CSR file: %(err)s') - % {'err': e})) - if not csr: - raise errors.CertificateOperationError( - error=(_('Generated CSR was empty'))) + pubkey_info = adaptor.get_subject_public_key_info() + pubkey_info_b64 = base64.b64encode(pubkey_info) + + # If csr_profile_id is passed, that takes precedence. + # Otherwise, use profile_id. If neither are passed, the default + # in cert_get_requestdata will be used. + profile_id = csr_profile_id + if profile_id is None: + profile_id = options.get('profile_id') + + response = self.api.Command.cert_get_requestdata( + profile_id=profile_id, + principal=options.get('principal'), + public_key_info=unicode(pubkey_info_b64)) + + req_info_b64 = response['result']['request_info'] + req_info = base64.b64decode(req_info_b64) + + csr = adaptor.sign_csr(req_info) + + if not csr: + raise errors.CertificateOperationError( + error=(_('Generated CSR was empty'))) + + # cert_request requires the CSR to be base64-encoded (but PEM + # header and footer are not required) + csr = unicode(base64.b64encode(csr)) else: if database is not None or private_key is not None: raise errors.MutuallyExclusiveError(reason=_( diff --git a/ipaclient/plugins/csrgen.py b/ipaclient/plugins/csrgen.py index 0d6eca0..958732d 100644 --- a/ipaclient/plugins/csrgen.py +++ b/ipaclient/plugins/csrgen.py @@ -4,13 +4,13 @@ import six -from ipaclient.csrgen import CSRGenerator, FileRuleProvider +from ipaclient import csrgen from ipalib import api from ipalib import errors from ipalib import output from ipalib import util from ipalib.frontend import Local, Str -from ipalib.parameters import Principal +from ipalib.parameters import File, Principal from ipalib.plugable import Registry from ipalib.text import _ from ipapython import dogtag @@ -41,15 +41,14 @@ class cert_get_requestdata(Local): label=_('Profile ID'), doc=_('CSR Generation Profile to use'), ), - Str( - 'helper', - label=_('Name of CSR generation tool'), - doc=_('Name of tool (e.g. openssl, certutil) that will be used to' - ' create CSR'), + File( + 'public_key_info', + label=_('Subject Public Key Info'), + doc=_('DER-encoded SubjectPublicKeyInfo structure'), ), Str( 'out?', - doc=_('Write CSR generation script to file'), + doc=_('Write CertificationRequestInfo to file'), ), ) @@ -63,8 +62,8 @@ class cert_get_requestdata(Local): has_output_params = ( Str( - 'script', - label=_('Generation script'), + 'request_info', + label=_('CertificationRequestInfo structure'), ) ) @@ -76,7 +75,7 @@ def execute(self, *args, **options): profile_id = options.get('profile_id') if profile_id is None: profile_id = dogtag.DEFAULT_PROFILE - helper = options.get('helper') + public_key_info = options.get('public_key_info') if self.api.env.in_server: backend = self.api.Backend.ldap2 @@ -101,17 +100,18 @@ def execute(self, *args, **options): principal_obj = principal_obj['result'] config = api.Command.config_show()['result'] - generator = CSRGenerator(FileRuleProvider()) + generator = csrgen.CSRGenerator(csrgen.FileRuleProvider()) - script = generator.csr_script( - principal_obj, config, profile_id, helper) + csr_data = generator.csr_script( + principal_obj, config, profile_id, 'certmonger') + request_info = csrgen.build_requestinfo(csr_data, public_key_info) result = {} if 'out' in options: with open(options['out'], 'wb') as f: - f.write(script) + f.write(request_info) else: - result = dict(script=script) + result = dict(request_info=request_info) return dict( result=result From ff6f9389d1947afa35e84f120418b30eb9ff5ed6 Mon Sep 17 00:00:00 2001 From: Ben Lipton <blip...@redhat.com> Date: Mon, 30 Jan 2017 10:51:11 -0500 Subject: [PATCH 3/3] csrgen: Beginnings of NSS database support https://pagure.io/freeipa/issue/4899 --- ipaclient/csrgen.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/ipaclient/csrgen.py b/ipaclient/csrgen.py index 9033eb2..f059821 100644 --- a/ipaclient/csrgen.py +++ b/ipaclient/csrgen.py @@ -3,7 +3,9 @@ # import collections +import base64 import json +import os import os.path import pipes import subprocess @@ -15,6 +17,7 @@ from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.serialization import ( load_pem_private_key, Encoding, PublicFormat) +from cryptography.x509 import load_pem_x509_certificate import jinja2 import jinja2.ext import jinja2.sandbox @@ -455,8 +458,30 @@ def sign_csr(self, certification_request_info): class NSSAdaptor(object): + def __init__(self, database, password_filename): + self.database = database + self.password_filename = password_filename + self.nickname = base64.b32encode(os.urandom(40)) + def get_subject_public_key_info(self): - raise NotImplementedError('NSS is not yet supported') + temp_cn = base64.b32encode(os.urandom(40)) + + password_args = [] + if self.password_filename is not None: + password_args = ['-f', self.password_filename] + + subprocess.check_call( + ['certutil', '-S', '-n', self.nickname, '-s', 'CN=%s' % temp_cn, + '-x', '-t', ',,', '-d', self.database] + password_args) + cert_pem = subprocess.check_output( + ['certutil', '-L', '-n', self.nickname, '-a', '-d', self.database] + + password_args) + + cert = load_pem_x509_certificate(cert_pem, default_backend()) + pubkey_info = cert.public_key().public_bytes( + Encoding.DER, PublicFormat.SubjectPublicKeyInfo) + + return pubkey_info def sign_csr(self, certification_request_info): raise NotImplementedError('NSS is not yet supported')
-- Manage your subscription for the Freeipa-devel mailing list: https://www.redhat.com/mailman/listinfo/freeipa-devel Contribute to FreeIPA: http://www.freeipa.org/page/Contribute/Code