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

Reply via email to