The branch, master has been updated via f813f8a54ae Update WHATSNEW for Certificate Auto Enrollment via fd6df5356b7 gpo: Test Certificate Auto Enrollment Policy via 9f0e6f3c063 gpo: Fix up rsop output of ca certificate via 9c0a174af20 gpo: Add Certificate Auto Enrollment Policy from cca9ce5977c WHATSNEW: Start release notes for Samba 4.16.0pre1.
https://git.samba.org/?p=samba.git;a=shortlog;h=master - Log ----------------------------------------------------------------- commit f813f8a54ae79dd74a99593aeacb252061688807 Author: David Mulder <dmul...@suse.com> Date: Mon Jul 12 15:18:04 2021 -0600 Update WHATSNEW for Certificate Auto Enrollment Signed-off-by: David Mulder <dmul...@suse.com> Reviewed-by: Jeremy Allison <j...@samba.org> Autobuild-User(master): Jeremy Allison <j...@samba.org> Autobuild-Date(master): Thu Jul 15 20:03:45 UTC 2021 on sn-devel-184 commit fd6df5356b7aa180d538a734799b640c1430eb47 Author: David Mulder <dmul...@samba.org> Date: Fri Jul 2 20:44:43 2021 +0000 gpo: Test Certificate Auto Enrollment Policy Signed-off-by: David Mulder <dmul...@samba.org> Reviewed-by: Jeremy Allison <j...@samba.org> commit 9f0e6f3c0631fdd8bd9580db382d00c2ea4f3c57 Author: David Mulder <dmul...@suse.com> Date: Mon Jun 28 09:06:09 2021 -0600 gpo: Fix up rsop output of ca certificate Signed-off-by: David Mulder <dmul...@suse.com> Reviewed-by: Jeremy Allison <j...@samba.org> commit 9c0a174af2007476cbff859f962a2667bc5004bf Author: David Mulder <dmul...@suse.com> Date: Thu Jun 17 09:13:12 2021 -0600 gpo: Add Certificate Auto Enrollment Policy Signed-off-by: David Mulder <dmul...@suse.com> Reviewed-by: Jeremy Allison <j...@samba.org> ----------------------------------------------------------------------- Summary of changes: WHATSNEW.txt | 13 ++ python/samba/gp_cert_auto_enroll_ext.py | 244 ++++++++++++++++++++++++++++++++ python/samba/gpclass.py | 6 +- python/samba/tests/bin/cepces-submit | 15 ++ python/samba/tests/bin/getcert | 84 +++++++++++ python/samba/tests/bin/sscep | 19 +++ python/samba/tests/gpo.py | 124 ++++++++++++++++ python/samba/tests/usage.py | 1 + source4/scripting/bin/samba-gpupdate | 2 + source4/selftest/tests.py | 4 +- 10 files changed, 508 insertions(+), 4 deletions(-) create mode 100644 python/samba/gp_cert_auto_enroll_ext.py create mode 100755 python/samba/tests/bin/cepces-submit create mode 100755 python/samba/tests/bin/getcert create mode 100755 python/samba/tests/bin/sscep Changeset truncated at 500 lines: diff --git a/WHATSNEW.txt b/WHATSNEW.txt index f3db6341e06..fe9eff8ba59 100644 --- a/WHATSNEW.txt +++ b/WHATSNEW.txt @@ -16,6 +16,19 @@ UPGRADING NEW FEATURES/CHANGES ==================== +Certificate Auto Enrollment +--------------------------- + +Certificate Auto Enrollment allows devices to enroll for certificates from +Active Directory Certificate Services. It is enabled by Group Policy. +To enable Certificate Auto Enrollment, Samba's group policy will need to be +enabled by setting the smb.conf option `apply group policies` to Yes. Samba +Certificate Auto Enrollment depends on certmonger, the cepces certmonger +plugin, and sscep. Samba uses sscep to download the CA root chain, then uses +certmonger paired with cepces to monitor the host certificate templates. +Certificates are installed in /var/lib/samba/certs and private keys are +installed in /var/lib/samba/private/certs. + REMOVED FEATURES ================ diff --git a/python/samba/gp_cert_auto_enroll_ext.py b/python/samba/gp_cert_auto_enroll_ext.py new file mode 100644 index 00000000000..556be604621 --- /dev/null +++ b/python/samba/gp_cert_auto_enroll_ext.py @@ -0,0 +1,244 @@ +# gp_cert_auto_enroll_ext samba group policy +# Copyright (C) David Mulder <dmul...@suse.com> 2021 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# 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 os +from samba.gpclass import gp_pol_ext +from samba import Ldb +from ldb import SCOPE_SUBTREE +from samba.auth import system_session +from samba.gpclass import get_dc_hostname +import base64 +from tempfile import NamedTemporaryFile +from shutil import move, which +from subprocess import Popen, PIPE +import re +from glob import glob +import json + +cert_wrap = b""" +-----BEGIN CERTIFICATE----- +%s +-----END CERTIFICATE-----""" +global_trust_dir = '/etc/pki/trust/anchors' + +def fetch_certification_authorities(ldb): + result = [] + basedn = ldb.get_default_basedn() + dn = 'CN=Certification Authorities,CN=Public Key Services,CN=Services,CN=Configuration,%s' % basedn + expr = '(objectClass=certificationAuthority)' + res = ldb.search(dn, SCOPE_SUBTREE, expr, ['cn']) + if len(res) == 0: + return result + dn = 'CN=Enrollment Services,CN=Public Key Services,CN=Services,CN=Configuration,%s' % basedn + attrs = ['cACertificate', 'cn', 'certificateTemplates', 'dNSHostName'] + for ca in res: + expr = '(cn=%s)' % ca['cn'] + res2 = ldb.search(dn, SCOPE_SUBTREE, expr, attrs) + if len(res) != 1: + continue + templates = {} + for template in res2[0]['certificateTemplates']: + templates[template] = fetch_template_attrs(ldb, template) + res = dict(res2[0]) + res['certificateTemplates'] = templates + result.append(res) + return result + +def fetch_template_attrs(ldb, name, attrs=['msPKI-Minimal-Key-Size']): + basedn = ldb.get_default_basedn() + dn = 'CN=Certificate Templates,CN=Public Key Services,CN=Services,CN=Configuration,%s' % basedn + expr = '(cn=%s)' % name + res = ldb.search(dn, SCOPE_SUBTREE, expr, attrs) + if len(res) == 1 and 'msPKI-Minimal-Key-Size' in res[0]: + return dict(res[0]) + else: + return {'msPKI-Minimal-Key-Size': ['2048']} + +def format_root_cert(cert): + cert = base64.b64encode(cert) + return cert_wrap % re.sub(b"(.{64})", b"\\1\n", cert, 0, re.DOTALL) + +def find_cepces_submit(): + certmonger_dirs = [os.environ.get("PATH"), '/usr/lib/certmonger', + '/usr/libexec/certmonger'] + return which('cepces-submit', path=':'.join(certmonger_dirs)) + +def get_supported_templates(server): + cepces_submit = find_cepces_submit() + if os.path.exists(cepces_submit): + env = os.environ + env['CERTMONGER_OPERATION'] = 'GET-SUPPORTED-TEMPLATES' + out, _ = Popen([cepces_submit, '--server=%s' % server], env=env, + stdout=PIPE, stderr=PIPE).communicate() + return out.strip().split() + return [] + +def cert_enroll(ca, trust_dir, private_dir, logger): + # Install the root certificate chain + data = {'files': [], 'templates': []} + sscep = which('sscep') + if sscep is not None: + url = 'http://%s/CertSrv/mscep/mscep.dll/pkiclient.exe?' % \ + ca['dNSHostName'][0] + root_cert = os.path.join(trust_dir, '%s.crt' % ca['cn']) + ret = Popen([sscep, 'getca', '-F', 'sha1', '-c', + root_cert, '-u', url]).wait() + if ret != 0: + logger.warn('sscep failed to fetch the root certificate chain.') + root_certs = glob('%s*' % root_cert) + data['files'].extend(root_certs) + for src in root_certs: + # Symlink the certs to global trust dir + dst = os.path.join(global_trust_dir, os.path.basename(src)) + try: + os.symlink(src, dst) + data['files'].append(dst) + except PermissionError: + logger.warn('Failed to symlink root certificate to the' + + ' admin trust anchors') + except FileNotFoundError: + logger.warn('Failed to symlink root certificate to the' + + ' admin trust anchors.' + + ' The directory %s was not found' % \ + global_trust_dir) + else: + logger.warn('sscep is not installed, which prevents the installation' + + ' of the root certificate chain.') + update = which('update-ca-certificates') + if update is not None: + Popen([update]).wait() + # Setup Certificate Auto Enrollment + getcert = which('getcert') + cepces_submit = find_cepces_submit() + if getcert is not None and os.path.exists(cepces_submit): + Popen([getcert, 'add-ca', '-c', ca['cn'][0], '-e', + '%s --server=%s' % (cepces_submit, ca['dNSHostName'][0])]).wait() + supported_templates = get_supported_templates(ca['dNSHostName'][0]) + for template, attrs in ca['certificateTemplates'].items(): + if template not in supported_templates: + continue + nickname = '%s.%s' % (ca['cn'][0], template.decode()) + keyfile = os.path.join(private_dir, '%s.key' % nickname) + certfile = os.path.join(trust_dir, '%s.crt' % nickname) + Popen([getcert, 'request', '-c', ca['cn'][0], + '-T', template.decode(), + '-I', nickname, '-k', keyfile, '-f', certfile, + '-g', attrs['msPKI-Minimal-Key-Size'][0]]).wait() + data['files'].extend([keyfile, certfile]) + data['templates'].append(nickname) + if update is not None: + Popen([update]).wait() + else: + logger.warn('certmonger and cepces must be installed for ' + + 'certificate auto enrollment to work') + return json.dumps(data) + +class gp_cert_auto_enroll_ext(gp_pol_ext): + def __str__(self): + return 'Cryptography\AutoEnrollment' + + def process_group_policy(self, deleted_gpo_list, changed_gpo_list, + trust_dir=None, private_dir=None): + if trust_dir is None: + trust_dir = self.lp.cache_path('certs') + if private_dir is None: + private_dir = self.lp.private_path('certs') + if not os.path.exists(trust_dir): + os.mkdir(trust_dir, mode=0o755) + if not os.path.exists(private_dir): + os.mkdir(private_dir, mode=0o700) + + for guid, settings in deleted_gpo_list: + self.gp_db.set_guid(guid) + if str(self) in settings: + for ca_cn_enc, data in settings[str(self)].items(): + ca_cn = base64.b64decode(ca_cn_enc) + data = json.loads(data) + getcert = which('getcert') + if getcert is not None: + Popen([getcert, 'remove-ca', '-c', ca_cn]).wait() + for nickname in data['templates']: + Popen([getcert, 'stop-tracking', + '-i', nickname]).wait() + for f in data['files']: + if os.path.exists(f): + os.unlink(f) + self.gp_db.delete(str(self), ca_cn_enc) + self.gp_db.commit() + + for gpo in changed_gpo_list: + if gpo.file_sys_path: + section = 'Software\Policies\Microsoft\Cryptography\AutoEnrollment' + self.gp_db.set_guid(gpo.name) + pol_file = 'MACHINE/Registry.pol' + path = os.path.join(gpo.file_sys_path, pol_file) + pol_conf = self.parse(path) + if not pol_conf: + continue + for e in pol_conf.entries: + if e.keyname == section and e.valuename == 'AEPolicy': + # This policy applies as specified in [MS-CAESO] 4.4.5.1 + if e.data == 0x8000: + continue # The policy is disabled + enroll = e.data & 0x1 == 1 + manage = e.data & 0x2 == 1 + retrive_pending = e.data & 0x4 == 1 + if enroll: + url = 'ldap://%s' % get_dc_hostname(self.creds, + self.lp) + ldb = Ldb(url=url, session_info=system_session(), + lp=self.lp, credentials=self.creds) + cas = fetch_certification_authorities(ldb) + for ca in cas: + data = cert_enroll(ca, trust_dir, + private_dir, self.logger) + self.gp_db.store(str(self), + base64.b64encode(ca['cn'][0]).decode(), + data) + self.gp_db.commit() + + def rsop(self, gpo): + output = {} + pol_file = 'MACHINE/Registry.pol' + section = 'Software\Policies\Microsoft\Cryptography\AutoEnrollment' + if gpo.file_sys_path: + path = os.path.join(gpo.file_sys_path, pol_file) + pol_conf = self.parse(path) + if not pol_conf: + return output + for e in pol_conf.entries: + if e.keyname == section and e.valuename == 'AEPolicy': + enroll = e.data & 0x1 == 1 + if e.data == 0x8000 or not enroll: + continue + output['Auto Enrollment Policy'] = {} + url = 'ldap://%s' % get_dc_hostname(self.creds, self.lp) + ldb = Ldb(url=url, session_info=system_session(), + lp=self.lp, credentials=self.creds) + cas = fetch_certification_authorities(ldb) + for ca in cas: + policy = 'Auto Enrollment Policy' + cn = ca['cn'][0] + output[policy][cn] = {} + output[policy][cn]['CA Certificate'] = \ + format_root_cert(ca['cACertificate'][0]).decode() + output[policy][cn]['Auto Enrollment Server'] = \ + ca['dNSHostName'][0] + supported_templates = \ + get_supported_templates(ca['dNSHostName'][0]) + output[policy][cn]['Templates'] = \ + [t.decode() for t in supported_templates] + return output diff --git a/python/samba/gpclass.py b/python/samba/gpclass.py index 7d3841ba8da..6879719847f 100644 --- a/python/samba/gpclass.py +++ b/python/samba/gpclass.py @@ -500,10 +500,10 @@ def __rsop_vals(vals, level=4): if type(vals) == dict: ret = [' '*level + '[ %s ] = %s' % (k, __rsop_vals(v, level+2)) for k, v in vals.items()] - return '\n'.join(ret) + return '\n' + '\n'.join(ret) elif type(vals) == list: ret = [' '*level + '[ %s ]' % __rsop_vals(v, level+2) for v in vals] - return '\n'.join(ret) + return '\n' + '\n'.join(ret) else: return vals @@ -532,7 +532,7 @@ def rsop(lp, creds, logger, store, gp_extensions, target): for section, settings in ext.rsop(gpo).items(): print(' Policy Type: %s' % section) print(' ' + ('-'*int(term_width/2))) - print(__rsop_vals(settings)) + print(__rsop_vals(settings).lstrip('\n')) print(' ' + ('-'*int(term_width/2))) print(' ' + ('-'*int(term_width/2))) print('%s\n' % ('='*term_width)) diff --git a/python/samba/tests/bin/cepces-submit b/python/samba/tests/bin/cepces-submit new file mode 100755 index 00000000000..1f9d57c6bfb --- /dev/null +++ b/python/samba/tests/bin/cepces-submit @@ -0,0 +1,15 @@ +#!/usr/bin/python3 +import optparse +import os, sys, re + +sys.path.insert(0, "bin/python") + +if __name__ == "__main__": + parser = optparse.OptionParser('cepces-submit [options]') + parser.add_option('--server') + + (opts, args) = parser.parse_args() + assert opts.server is not None + if 'CERTMONGER_OPERATION' in os.environ and \ + os.environ['CERTMONGER_OPERATION'] == 'GET-SUPPORTED-TEMPLATES': + print('Machine') # Report a Machine template diff --git a/python/samba/tests/bin/getcert b/python/samba/tests/bin/getcert new file mode 100755 index 00000000000..93895ebe132 --- /dev/null +++ b/python/samba/tests/bin/getcert @@ -0,0 +1,84 @@ +#!/usr/bin/python3 +import optparse +import os, sys, re +import pickle + +sys.path.insert(0, "bin/python") + +if __name__ == "__main__": + parser = optparse.OptionParser('getcert <cmd> [options]') + parser.add_option('-i') + parser.add_option('-c') + parser.add_option('-T') + parser.add_option('-I') + parser.add_option('-k') + parser.add_option('-f') + parser.add_option('-e') + parser.add_option('-g') + + (opts, args) = parser.parse_args() + assert len(args) == 1 + assert args[0] in ['add-ca', 'request', 'remove-ca', 'stop-tracking', + 'list', 'list-cas'] + + # Use a dir we can write to in the testenv + if 'LOCAL_PATH' in os.environ: + data_dir = os.path.realpath(os.environ.get('LOCAL_PATH')) + else: + data_dir = os.path.dirname(os.path.realpath(__file__)) + dump_file = os.path.join(data_dir, 'getcert.dump') + if os.path.exists(dump_file): + with open(dump_file, 'rb') as r: + cas, certs = pickle.load(r) + else: + cas = {} + certs = {} + if args[0] == 'add-ca': + # Add a fake CA entry + assert opts.c not in cas.keys() + cas[opts.c] = opts.e + elif args[0] == 'remove-ca': + # Remove a fake CA entry + assert opts.c in cas.keys() + del cas[opts.c] + elif args[0] == 'list-cas': + # List the fake CAs + for ca, helper_location in cas.items(): + print('CA \'%s\':\n\tis-default: no\n\tca-type: EXTERNAL\n' % ca + + '\thelper-location: %s' % helper_location) + elif args[0] == 'request': + # Add a fake cert request + assert opts.c in cas.keys() + assert opts.I not in certs.keys() + certs[opts.I] = { 'ca': opts.c, 'template': opts.T, + 'keyfile': os.path.abspath(opts.k), + 'certfile': os.path.abspath(opts.f), + 'keysize': opts.g } + # Create dummy key and cert (empty files) + with open(opts.k, 'w') as w: + pass + with open(opts.f, 'w') as w: + pass + elif args[0] == 'stop-tracking': + # Remove the fake cert request + assert opts.i in certs.keys() + del certs[opts.i] + elif args[0] == 'list': + # List the fake cert requests + print('Number of certificates and requests being tracked: %d.' % \ + len(certs)) + for rid, data in certs.items(): + print('Request ID \'%s\':\n\tstatus: MONITORING\n' % rid + + '\tstuck: no\n\tkey pair storage: type=FILE,' + + 'location=\'%s\'' % data['keyfile'] + '\n\t' + + 'certificate: type=FILE,location=\'%s\'' % data['certfile'] + + '\n\tCA: %s\n\t' % data['ca'] + + 'certificate template/profile: %s\n\t' % data['template'] + + 'track: yes\n\tauto-renew: yes') + + if len(cas.items()) == 0 and len(certs.items()) == 0: + if os.path.exists(dump_file): + os.unlink(dump_file) + else: + with open(dump_file, 'wb') as w: + pickle.dump((cas, certs), w) diff --git a/python/samba/tests/bin/sscep b/python/samba/tests/bin/sscep new file mode 100755 index 00000000000..d0d88926766 --- /dev/null +++ b/python/samba/tests/bin/sscep @@ -0,0 +1,19 @@ +#!/usr/bin/python3 +import optparse +import os, sys, re + +sys.path.insert(0, "bin/python") + +if __name__ == "__main__": + parser = optparse.OptionParser('sscep <cmd> [options]') + parser.add_option('-F') + parser.add_option('-c') + parser.add_option('-u') + + (opts, args) = parser.parse_args() + assert len(args) == 1 + assert args[0] == 'getca' + assert opts.F == 'sha1' + # Create dummy root cert (empty file) + with open(opts.c, 'w') as w: + pass diff --git a/python/samba/tests/gpo.py b/python/samba/tests/gpo.py index 4df0c23c456..b5dc09543ad 100644 --- a/python/samba/tests/gpo.py +++ b/python/samba/tests/gpo.py @@ -38,6 +38,7 @@ from samba.vgp_motd_ext import vgp_motd_ext from samba.vgp_issue_ext import vgp_issue_ext from samba.vgp_access_ext import vgp_access_ext from samba.gp_gnome_settings_ext import gp_gnome_settings_ext +from samba.gp_cert_auto_enroll_ext import gp_cert_auto_enroll_ext import logging from samba.credentials import Credentials from samba.gp_msgs_ext import gp_msgs_ext @@ -51,6 +52,9 @@ import hashlib from samba.gp_parse.gp_pol import GPPolParser from glob import glob from configparser import ConfigParser +from samba.gpclass import get_dc_hostname +from samba import Ldb +from samba.auth import system_session realm = os.environ.get('REALM') policies = realm + '/POLICIES' @@ -198,6 +202,28 @@ b""" </PolFile> """ +auto_enroll_reg_pol = \ +b""" +<?xml version="1.0" encoding="utf-8"?> +<PolFile num_entries="3" signature="PReg" version="1"> + <Entry type="4" type_name="REG_DWORD"> + <Key>Software\Policies\Microsoft\Cryptography\AutoEnrollment</Key> + <ValueName>AEPolicy</ValueName> + <Value>7</Value> + </Entry> + <Entry type="4" type_name="REG_DWORD"> + <Key>Software\Policies\Microsoft\Cryptography\AutoEnrollment</Key> + <ValueName>OfflineExpirationPercent</ValueName> + <Value>10</Value> + </Entry> + <Entry type="1" type_name="REG_SZ"> + <Key>Software\Policies\Microsoft\Cryptography\AutoEnrollment</Key> + <ValueName>OfflineExpirationStoreNames</ValueName> + <Value>MY</Value> + </Entry> +</PolFile> +""" + def days2rel_nttime(val): seconds = 60 minutes = 60 @@ -1860,3 +1886,101 @@ class GPOTests(tests.TestCase): # Unstage the Registry.pol file unstage_file(reg_pol) + + def test_gp_cert_auto_enroll_ext(self): + local_path = self.lp.cache_path('gpo_cache') + guid = '{31B2F340-016D-11D2-945F-00C04FB984F9}' + reg_pol = os.path.join(local_path, policies, guid, + 'MACHINE/REGISTRY.POL') + logger = logging.getLogger('gpo_tests') + cache_dir = self.lp.get('cache directory') + store = GPOStorage(os.path.join(cache_dir, 'gpo.tdb')) -- Samba Shared Repository