From 265d8a1c3cc6eeb2b5242fcf2fe512826b0825b1 Mon Sep 17 00:00:00 2001 From: Gabriel Nagy Date: Wed, 26 Jul 2023 02:14:44 +0300 Subject: [PATCH] Add certificate policy manager This manager leverages the Samba implementation of certificate autoenrollment. It contains a few patches and fixes on top of the samba#master version of `gp_cert_auto_enroll_ext.py` file, that will ultimately be upstreamed. Autoenrollment is performed via a separate policy manager that runs a helper Python script (`cert-autoenroll`) which communicates with the Windows CEP/CES services through Samba. For better control and to avoid unexpected behavior we vendor the required Samba files, which are confirmed to work on all Ubuntu versions starting with (and including) Jammy (22.04). Samba has its own cache mechanism which stores information concerning the applied GPOs which we are using in order to ensure idempotency. By default, Samba would parse the .reg file itself (see the process_group_policy method from the vendored code). However, it is better to have this functionality entirely within adsys so we can provide samba the pre-parsed list of GPO entries and override the entry point of the extension. This ensures we don't operate on disk files which can change at anytime (even during adsys policy application). Doing this we also have better knowledge on the enabled/disabled state of the GPO entry used to configure the policy. The advanced configuration entries are passed via JSON to the external script which then takes care to create the proper PReg entries that can be used by Samba to apply additional logic when determining the policy servers to use. Fixes UDENG-1056 --- internal/consts/consts.go | 6 + internal/policies/certificate/cert-autoenroll | 131 +++ .../certificate/cert-autoenroll_test.go | 141 +++ internal/policies/certificate/certificate.go | 260 ++++++ .../policies/certificate/certificate_test.go | 155 +++ .../gp/gp_cert_auto_enroll_ext.py | 536 +++++++++++ .../python/vendor_samba/gp/gpclass.py | 884 ++++++++++++++++++ .../python/vendor_samba/gp/util/logging.py | 112 +++ .../enroll_with_empty_advanced_configuration | 11 + .../golden/enroll_with_simple_configuration | 11 + ...ith_simple_configuration_and_debug_enabled | 12 + .../enroll_with_valid_advanced_configuration | 48 + .../TestCertAutoenrollScript/golden/unenroll | 8 + .../golden/computer,_configured_to_enroll | 3 + ...nfigured_to_enroll,_advanced_configuration | 3 + .../golden/computer,_configured_to_unenroll | 3 + .../admock/samba/credentials/__init__.py | 3 + .../testutils/admock/samba/dcerpc/preg.py | 9 + .../testutils/admock/samba/param/__init__.py | 8 +- .../testutils/admock/vendor_samba/__init__.py | 0 .../admock/vendor_samba/gp/__init__.py | 0 .../gp/gp_cert_auto_enroll_ext.py | 38 + .../admock/vendor_samba/gp/gpclass.py | 4 + 23 files changed, 2384 insertions(+), 2 deletions(-) create mode 100755 internal/policies/certificate/cert-autoenroll create mode 100644 internal/policies/certificate/cert-autoenroll_test.go create mode 100644 internal/policies/certificate/certificate.go create mode 100644 internal/policies/certificate/certificate_test.go create mode 100644 internal/policies/certificate/python/vendor_samba/gp/gp_cert_auto_enroll_ext.py create mode 100644 internal/policies/certificate/python/vendor_samba/gp/gpclass.py create mode 100644 internal/policies/certificate/python/vendor_samba/gp/util/logging.py create mode 100644 internal/policies/certificate/testdata/TestCertAutoenrollScript/golden/enroll_with_empty_advanced_configuration create mode 100644 internal/policies/certificate/testdata/TestCertAutoenrollScript/golden/enroll_with_simple_configuration create mode 100644 internal/policies/certificate/testdata/TestCertAutoenrollScript/golden/enroll_with_simple_configuration_and_debug_enabled create mode 100644 internal/policies/certificate/testdata/TestCertAutoenrollScript/golden/enroll_with_valid_advanced_configuration create mode 100644 internal/policies/certificate/testdata/TestCertAutoenrollScript/golden/unenroll create mode 100644 internal/policies/certificate/testdata/TestPolicyApply/golden/computer,_configured_to_enroll create mode 100644 internal/policies/certificate/testdata/TestPolicyApply/golden/computer,_configured_to_enroll,_advanced_configuration create mode 100644 internal/policies/certificate/testdata/TestPolicyApply/golden/computer,_configured_to_unenroll create mode 100644 internal/testutils/admock/samba/dcerpc/preg.py create mode 100644 internal/testutils/admock/vendor_samba/__init__.py create mode 100644 internal/testutils/admock/vendor_samba/gp/__init__.py create mode 100644 internal/testutils/admock/vendor_samba/gp/gp_cert_auto_enroll_ext.py create mode 100644 internal/testutils/admock/vendor_samba/gp/gpclass.py diff --git a/internal/consts/consts.go b/internal/consts/consts.go index c44f66d56..e53cb0017 100644 --- a/internal/consts/consts.go +++ b/internal/consts/consts.go @@ -21,9 +21,15 @@ const ( // DefaultCacheDir is the default path for adsys system cache directory. DefaultCacheDir = "/var/cache/adsys" + // DefaultStateDir is the default path for adsys system state directory. + DefaultStateDir = "/var/lib/adsys" + // DefaultRunDir is the default path for adsys run directory. DefaultRunDir = "/run/adsys" + // DefaultShareDir is the default path for adsys share directory. + DefaultShareDir = "/usr/share/adsys" + // DefaultClientTimeout is the maximum default time in seconds between 2 server activities before the client returns and abort the request. DefaultClientTimeout = 30 diff --git a/internal/policies/certificate/cert-autoenroll b/internal/policies/certificate/cert-autoenroll new file mode 100755 index 000000000..02e0a3a35 --- /dev/null +++ b/internal/policies/certificate/cert-autoenroll @@ -0,0 +1,131 @@ +#!/usr/bin/python3 + +import argparse +import json +import os +import sys +import tempfile + +from collections import namedtuple + +from samba import param +from samba.credentials import MUST_USE_KERBEROS, Credentials +from samba.dcerpc import preg + +from vendor_samba.gp.gpclass import GPOStorage +from vendor_samba.gp import gp_cert_auto_enroll_ext as cae + +class adsys_cert_auto_enroll(cae.gp_cert_auto_enroll_ext): + def enroll(self, guid, entries, trust_dir, private_dir, global_trust_dir): + self._gp_cert_auto_enroll_ext__enroll(guid, entries, trust_dir, private_dir, global_trust_dir) + + def unenroll(self, guid): + ca_attrs = self.cache_get_all_attribute_values(guid) + self.clean(guid, remove=list(ca_attrs.keys())) + +def smb_config(realm, enable_debug): + config = "[global]\nrealm = %s\n" % realm + if enable_debug: + config += "log level = 10\n" + return config + +def main(): + parser = argparse.ArgumentParser(description='Certificate autoenrollment via Samba') + parser.add_argument('action', type=str, + help='Action to perform (one of: enroll, unenroll)', + choices=['enroll', 'unenroll']) + parser.add_argument('object_name', type=str, + help='The computer name to enroll/unenroll, e.g. keypress') + parser.add_argument('realm', type=str, + help='The realm of the domain, e.g. example.com') + + parser.add_argument('--policy_servers_json', type=str, + help='GPO entries for advanced configuration of the policy servers. \ + Must be in JSON format.') + parser.add_argument('--state_dir', type=str, + default='/var/lib/adsys', + help='Directory to store GPO state in.') + parser.add_argument('--private_dir', type=str, + default='/var/lib/adsys/private/certs', + help='Directory to store private keys in.') + parser.add_argument('--trust_dir', type=str, + default='/var/lib/adsys/certs', + help='Directory to store trusted certificates in.') + parser.add_argument('--global_trust_dir', type=str, + default='/usr/local/share/ca-certificates', + help='Directory to symlink root CA certificates to.') + parser.add_argument('--debug', action='store_true', + help='Enable samba debug output.') + + args = parser.parse_args() + + state_dir = args.state_dir + trust_dir = args.trust_dir + private_dir = args.private_dir + global_trust_dir = args.global_trust_dir + + # Create needed directories if they don't exist + for directory in [state_dir, trust_dir, private_dir]: + if not os.path.exists(directory): + os.makedirs(directory) + + with tempfile.NamedTemporaryFile(prefix='smb_conf') as smb_conf: + smb_conf.write(smb_config(args.realm, args.debug).encode('utf-8')) + smb_conf.flush() + + lp = param.LoadParm(smb_conf.name) + c = Credentials() + c.set_kerberos_state(MUST_USE_KERBEROS) + c.guess(lp) + username = c.get_username() + store = GPOStorage(os.path.join(state_dir, f'cert_gpo_state_{args.object_name}.tdb')) + + ext = adsys_cert_auto_enroll(lp, c, username, store) + guid = f'adsys-cert-autoenroll-{args.object_name}' + if args.action == 'enroll': + entries = gpo_entries(args.policy_servers_json) + ext.enroll(guid, entries, trust_dir, private_dir, global_trust_dir) + else: + ext.unenroll(guid) + +def gpo_entries(entries_json): + """ + Convert JSON string to list of GPO entries + + JSON must be an array of objects with the following keys: + keyname (str): Registry key name + valuename (str): Registry value name + type (int): Registry value type + data (any): Registry value data + + Parameters: + entries_json (str): JSON string of GPO entries + Returns: + list: List of GPO entries, or empty list if entries_json is empty + """ + + if not entries_json: + return [] + + entries_dict = json.loads(entries_json) + if not entries_dict: + return [] + + entries = [] + for entry in entries_dict: + try: + e = preg.entry() + e.keyname = entry['keyname'] + e.valuename = entry['valuename'] + e.type = entry['type'] + e.data = entry['data'] + entries.append(e) + except KeyError as exc: + raise ValueError(f'Could not find key {exc} in GPO entry') from exc + except TypeError as exc: + raise ValueError(f'GPO data must be a JSON array of objects') from exc + return entries + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/internal/policies/certificate/cert-autoenroll_test.go b/internal/policies/certificate/cert-autoenroll_test.go new file mode 100644 index 000000000..f7006e73f --- /dev/null +++ b/internal/policies/certificate/cert-autoenroll_test.go @@ -0,0 +1,141 @@ +package certificate_test + +import ( + "bytes" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/ubuntu/adsys/internal/testutils" +) + +const advancedConfigurationJSON = `[ + { + "keyname": "Software\\Policies\\Microsoft\\Cryptography\\PolicyServers\\37c9dc30f207f27f61a2f7c3aed598a6e2920b54", + "valuename": "AuthFlags", + "data": 2, + "type": 4 + }, + { + "keyname": "Software\\Policies\\Microsoft\\Cryptography\\PolicyServers\\37c9dc30f207f27f61a2f7c3aed598a6e2920b54", + "valuename": "Cost", + "data": 2147483645, + "type": 4 + }, + { + "keyname": "Software\\Policies\\Microsoft\\Cryptography\\PolicyServers\\37c9dc30f207f27f61a2f7c3aed598a6e2920b54", + "valuename": "Flags", + "data": 20, + "type": 4 + }, + { + "keyname": "Software\\Policies\\Microsoft\\Cryptography\\PolicyServers\\37c9dc30f207f27f61a2f7c3aed598a6e2920b54", + "valuename": "FriendlyName", + "data": "ActiveDirectoryEnrollmentPolicy", + "type": 1 + }, + { + "keyname": "Software\\Policies\\Microsoft\\Cryptography\\PolicyServers\\37c9dc30f207f27f61a2f7c3aed598a6e2920b54", + "valuename": "PolicyID", + "data": "{A5E9BF57-71C6-443A-B7FC-79EFA6F73EBD}", + "type": 1 + }, + { + "keyname": "Software\\Policies\\Microsoft\\Cryptography\\PolicyServers\\37c9dc30f207f27f61a2f7c3aed598a6e2920b54", + "valuename": "URL", + "data": "LDAP:", + "type": 1 + }, + { + "keyname": "Software\\Policies\\Microsoft\\Cryptography\\PolicyServers", + "valuename": "Flags", + "data": 0, + "type": 4 + } +]` + +func TestCertAutoenrollScript(t *testing.T) { + t.Parallel() + + coverageOn := testutils.PythonCoverageToGoFormat(t, "cert-autoenroll", false) + certAutoenrollCmd := "./cert-autoenroll" + if coverageOn { + certAutoenrollCmd = "cert-autoenroll" + } + + compactedJSON := &bytes.Buffer{} + err := json.Compact(compactedJSON, []byte(advancedConfigurationJSON)) + require.NoError(t, err, "Failed to compact JSON") + + // Setup samba mock + pythonPath, err := filepath.Abs("../../testutils/admock") + require.NoError(t, err, "Setup: Failed to get current absolute path for mock") + + tests := map[string]struct { + args []string + + readOnlyPath bool + autoenrollError bool + + wantErr bool + }{ + "Enroll with simple configuration": {args: []string{"enroll", "keypress", "example.com"}}, + "Enroll with simple configuration and debug enabled": {args: []string{"enroll", "keypress", "example.com", "--debug"}}, + "Enroll with empty advanced configuration": {args: []string{"enroll", "keypress", "example.com", "--policy_servers_json", "null"}}, + "Enroll with valid advanced configuration": {args: []string{"enroll", "keypress", "example.com", "--policy_servers_json", compactedJSON.String()}}, + + "Unenroll": {args: []string{"unenroll", "keypress", "example.com"}}, + + // Error cases + "Error on missing arguments": {args: []string{"enroll"}, wantErr: true}, + "Error on invalid flags": {args: []string{"enroll", "keypress", "example.com", "--invalid_flag"}, wantErr: true}, + "Error on invalid JSON": {args: []string{"enroll", "keypress", "example.com", "--policy_servers_json", "invalid_json"}, wantErr: true}, + "Error on invalid JSON keys": { + args: []string{"enroll", "keypress", "example.com", "--policy_servers_json", `[{"key":"Software\\Policies\\Microsoft","value":"MyValue"}]`}, wantErr: true}, + "Error on invalid JSON structure": { + args: []string{"enroll", "keypress", "example.com", "--policy_servers_json", `{"key":"Software\\Policies\\Microsoft","value":"MyValue"}`}, wantErr: true}, + "Error on read-only path": {readOnlyPath: true, args: []string{"enroll", "keypress", "example.com"}, wantErr: true}, + "Error on enroll failure": {autoenrollError: true, args: []string{"enroll", "keypress", "example.com"}, wantErr: true}, + "Error on unenroll failure": {autoenrollError: true, args: []string{"unenroll", "keypress", "example.com"}, wantErr: true}, + } + + for name, tc := range tests { + tc := tc + name := name + t.Run(name, func(t *testing.T) { + t.Parallel() + + tmpdir := t.TempDir() + stateDir := filepath.Join(tmpdir, "state") + privateDir := filepath.Join(tmpdir, "private") + trustDir := filepath.Join(tmpdir, "trust") + globalTrustDir := filepath.Join(tmpdir, "ca-certificates") + + if tc.readOnlyPath { + testutils.MakeReadOnly(t, tmpdir) + } + + args := append(tc.args, "--state_dir", stateDir, "--private_dir", privateDir, "--trust_dir", trustDir, "--global_trust_dir", globalTrustDir) + + cmd := exec.Command(certAutoenrollCmd, args...) + cmd.Env = append(os.Environ(), "PYTHONPATH="+pythonPath) + if tc.autoenrollError { + cmd.Env = append(os.Environ(), "ADSYS_WANT_AUTOENROLL_ERROR=1") + } + out, err := cmd.CombinedOutput() + if tc.wantErr { + require.Error(t, err, "cert-autoenroll should have failed but didn’t") + return + } + require.NoErrorf(t, err, "cert-autoenroll should have exited successfully: %s", string(out)) + + got := strings.ReplaceAll(string(out), tmpdir, "#TMPDIR#") + want := testutils.LoadWithUpdateFromGolden(t, got) + require.Equal(t, want, got, "Unexpected output from cert-autoenroll script") + }) + } +} diff --git a/internal/policies/certificate/certificate.go b/internal/policies/certificate/certificate.go new file mode 100644 index 000000000..01d99888e --- /dev/null +++ b/internal/policies/certificate/certificate.go @@ -0,0 +1,260 @@ +// Package certificate provides a manager that handles certificate +// autoenrollment. +// +// This manager only applies to computer objects. +// +// Provided that the AD backend is online and AD CS is set up, the manager will +// parse the relevant GPOs and delegate to an external Python script that will +// request Samba to enroll or un-enroll the machine for certificates. +// +// No action is taken if the certificate GPO is disabled, not configured, or +// absent. +// If the enroll flag is not set, the machine will be un-enrolled, +// namely the certificates will be removed and monitoring will stop. +// If any errors occur during the enrollment process, the manager will log them +// prior to failing. +package certificate + +import ( + "bytes" + "context" + _ "embed" // embed cert enroll python script + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "github.com/ubuntu/adsys/internal/consts" + log "github.com/ubuntu/adsys/internal/grpc/logstreamer" + "github.com/ubuntu/adsys/internal/i18n" + "github.com/ubuntu/adsys/internal/policies/entry" + "github.com/ubuntu/adsys/internal/smbsafe" + "github.com/ubuntu/decorate" + "golang.org/x/exp/slices" +) + +// Manager prevents running multiple Python scripts in parallel while parsing +// the policy in ApplyPolicy. +type Manager struct { + domain string + stateDir string + krb5CacheDir string + vendorPythonDir string + certEnrollCmd []string + + mu sync.Mutex // Prevents multiple instances of the certificate manager from running in parallel +} + +// gpoEntry is a single GPO registry entry to be serialised to JSON in a format +// Samba expects. +type gpoEntry struct { + KeyName string `json:"keyname"` + ValueName string `json:"valuename"` + Data any `json:"data"` + Type int `json:"type"` +} + +// integerGPOValues is a list of GPO registry values that contain integer data. +var integerGPOValues = []string{"AuthFlags", "Cost", "Flags"} + +const ( + gpoTypeString int = 1 // REG_SZ + gpoTypeInteger int = 4 // REG_DWORD + + // See [MS-CAESO] 4.4.5.1. + enrollFlag int = 0x1 + disabledFlag int = 0x8000 +) + +// CertEnrollCode is the embedded Python script which requests +// Samba to autoenroll for certificates using the given GPOs. +// +//go:embed cert-autoenroll +var CertEnrollCode string + +type options struct { + stateDir string + runDir string + shareDir string + certAutoenrollCmd []string +} + +// Option reprents an optional function to change the certificate manager. +type Option func(*options) + +// WithStateDir overrides the default state directory. +func WithStateDir(p string) func(*options) { + return func(a *options) { + a.stateDir = p + } +} + +// WithRunDir overrides the default run directory. +func WithRunDir(p string) func(*options) { + return func(a *options) { + a.runDir = p + } +} + +// WithShareDir overrides the default share directory. +func WithShareDir(p string) func(*options) { + return func(a *options) { + a.shareDir = p + } +} + +// WithCertAutoenrollCmd overrides the default certificate autoenroll command. +func WithCertAutoenrollCmd(cmd []string) func(*options) { + return func(a *options) { + a.certAutoenrollCmd = cmd + } +} + +// New returns a new manager for the certificate policy. +func New(domain string, opts ...Option) *Manager { + // defaults + args := options{ + stateDir: consts.DefaultStateDir, + runDir: consts.DefaultRunDir, + shareDir: consts.DefaultShareDir, + certAutoenrollCmd: []string{"python3", "-c", CertEnrollCode}, + } + // applied options + for _, o := range opts { + o(&args) + } + + return &Manager{ + domain: domain, + stateDir: args.stateDir, + krb5CacheDir: filepath.Join(args.runDir, "krb5cc"), + vendorPythonDir: filepath.Join(args.shareDir, "python"), + certEnrollCmd: args.certAutoenrollCmd, + } +} + +// ApplyPolicy runs the certificate autoenrollment script to enroll or un-enroll the machine. +func (m *Manager) ApplyPolicy(ctx context.Context, objectName string, isComputer, isOnline bool, entries []entry.Entry) (err error) { + defer decorate.OnError(&err, i18n.G("can't apply certificate policy")) + + m.mu.Lock() + defer m.mu.Unlock() + + idx := slices.IndexFunc(entries, func(e entry.Entry) bool { return e.Key == "autoenroll" }) + if idx == -1 { + log.Debug(ctx, "Certificate autoenrollment is not configured") + return nil + } + + if !isComputer { + log.Debug(ctx, "Certificate policy is only supported for computers, skipping...") + return nil + } + + if !isOnline { + log.Info(ctx, i18n.G("AD backend is offline, skipping certificate policy")) + return nil + } + + log.Debug(ctx, "ApplyPolicy certificate policy") + + entry := entries[idx] + value, err := strconv.Atoi(entry.Value) + if err != nil { + return fmt.Errorf(i18n.G("failed to parse certificate policy entry value: %w"), err) + } + + if value&disabledFlag == disabledFlag { + log.Debug(ctx, "Certificate policy is disabled, skipping...") + return nil + } + + var polSrvRegistryEntries []gpoEntry + for _, entry := range entries { + // We already handled the autoenroll entry + if entry.Key == "autoenroll" { + continue + } + + // Samba expects the key parts to be joined by backslashes + keyparts := strings.Split(entry.Key, "/") + keyname := strings.Join(keyparts[:len(keyparts)-1], `\`) + valuename := keyparts[len(keyparts)-1] + polSrvRegistryEntries = append(polSrvRegistryEntries, gpoEntry{keyname, valuename, gpoData(entry.Value, valuename), gpoType(valuename)}) + + log.Debugf(ctx, "Certificate policy entry: %#v", entry) + } + + var action string + log.Debugf(ctx, "Certificate policy value: %d", value) + if value&enrollFlag == enrollFlag { + action = "enroll" + } else { + action = "unenroll" + } + + jsonGPOData, err := json.Marshal(polSrvRegistryEntries) + if err != nil { + return fmt.Errorf(i18n.G("failed to marshal policy server registry entries: %v"), err) + } + + if err := m.runScript(ctx, action, objectName, + "--policy_servers_json", string(jsonGPOData), + "--state_dir", m.stateDir, + ); err != nil { + return err + } + + return nil +} + +// runScript runs the certificate autoenrollment script with the given arguments. +func (m *Manager) runScript(ctx context.Context, action, objectName string, extraArgs ...string) error { + scriptArgs := []string{action, objectName, m.domain} + scriptArgs = append(scriptArgs, extraArgs...) + cmdArgs := append(m.certEnrollCmd, scriptArgs...) + cmdCtx, cancel := context.WithTimeout(ctx, time.Second*10) + defer cancel() + log.Debugf(ctx, "Running cert autoenroll script with arguments: %q", strings.Join(scriptArgs, " ")) + // #nosec G204 - cmdArgs is under our control (python embedded script or mock for tests) + cmd := exec.CommandContext(cmdCtx, cmdArgs[0], cmdArgs[1:]...) + cmd.Env = append(os.Environ(), + fmt.Sprintf("KRB5CCNAME=%s", filepath.Join(m.krb5CacheDir, objectName)), + fmt.Sprintf("PYTHONPATH=%s", m.vendorPythonDir), + ) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + smbsafe.WaitExec() + defer smbsafe.DoneExec() + + if err := cmd.Run(); err != nil { + return fmt.Errorf(i18n.G("failed to run certificate autoenrollment script (exited with %d): %v\n%s"), cmd.ProcessState.ExitCode(), err, stderr.String()) + } + log.Infof(ctx, i18n.G("Certificate autoenrollment script ran successfully\n%s"), stdout.String()) + return nil +} + +// gpoData returns the data for a GPO entry. +func gpoData(data, value string) any { + if slices.Contains(integerGPOValues, value) { + intData, _ := strconv.Atoi(data) + return intData + } + + return data +} + +// gpoType returns the type for a GPO entry. +func gpoType(value string) int { + if slices.Contains(integerGPOValues, value) { + return gpoTypeInteger + } + + return gpoTypeString +} diff --git a/internal/policies/certificate/certificate_test.go b/internal/policies/certificate/certificate_test.go new file mode 100644 index 000000000..688280ffd --- /dev/null +++ b/internal/policies/certificate/certificate_test.go @@ -0,0 +1,155 @@ +package certificate_test + +import ( + "context" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/ubuntu/adsys/internal/policies/certificate" + "github.com/ubuntu/adsys/internal/policies/entry" + "github.com/ubuntu/adsys/internal/testutils" +) + +const ( + enrollValue = "7" // string representation of 0b111 + unenrollValue = "6" // string representation of 0b110 + disabledValue = "32768" // string representation of 0x8000 +) + +var enrollEntry = entry.Entry{Key: "autoenroll", Value: enrollValue} +var advancedConfigurationEntries = []entry.Entry{ + {Key: "Software/Policies/Microsoft/Cryptography/PolicyServers/37c9dc30f207f27f61a2f7c3aed598a6e2920b54/AuthFlags", Value: "2"}, + {Key: "Software/Policies/Microsoft/Cryptography/PolicyServers/37c9dc30f207f27f61a2f7c3aed598a6e2920b54/Cost", Value: "2147483645"}, + {Key: "Software/Policies/Microsoft/Cryptography/PolicyServers/37c9dc30f207f27f61a2f7c3aed598a6e2920b54/Flags", Value: "20"}, + {Key: "Software/Policies/Microsoft/Cryptography/PolicyServers/37c9dc30f207f27f61a2f7c3aed598a6e2920b54/FriendlyName", Value: "ActiveDirectoryEnrollmentPolicy"}, + {Key: "Software/Policies/Microsoft/Cryptography/PolicyServers/37c9dc30f207f27f61a2f7c3aed598a6e2920b54/PolicyID", Value: "{A5E9BF57-71C6-443A-B7FC-79EFA6F73EBD}"}, + {Key: "Software/Policies/Microsoft/Cryptography/PolicyServers/37c9dc30f207f27f61a2f7c3aed598a6e2920b54/URL", Value: "LDAP:"}, + {Key: "Software/Policies/Microsoft/Cryptography/PolicyServers/Flags", Value: "0"}, +} + +func TestPolicyApply(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + entries []entry.Entry + + isUser bool + isOffline bool + + autoenrollScriptError bool + runScript bool + + wantErr bool + }{ + "Computer, no entries": {}, + "Computer, configured to enroll": {entries: []entry.Entry{enrollEntry}, runScript: true}, + "Computer, configured to enroll, advanced configuration": {entries: append(advancedConfigurationEntries, enrollEntry), runScript: true}, + "Computer, configured to unenroll": {entries: []entry.Entry{{Key: "autoenroll", Value: unenrollValue}}, runScript: true}, + "Computer, autoenroll disabled": {entries: []entry.Entry{{Key: "autoenroll", Value: disabledValue}}}, + "Computer, domain is offline": {entries: []entry.Entry{enrollEntry}, isOffline: true}, + + "User, autoenroll not supported": {isUser: true, entries: []entry.Entry{enrollEntry}}, + + // Error cases + "Error on autoenroll script failure": {autoenrollScriptError: true, entries: []entry.Entry{enrollEntry}, wantErr: true}, + "Error on invalid autoenroll value": {entries: []entry.Entry{{Key: "autoenroll", Value: "notanumber"}}, wantErr: true}, + } + + for name, tc := range tests { + tc := tc + name := name + t.Run(name, func(t *testing.T) { + t.Parallel() + + tmpdir := t.TempDir() + autoenrollCmdOutputFile := filepath.Join(tmpdir, "autoenroll-output") + autoenrollCmd := mockAutoenrollScript(t, autoenrollCmdOutputFile, tc.autoenrollScriptError) + + m := certificate.New( + "example.com", + certificate.WithStateDir(filepath.Join(tmpdir, "statedir")), + certificate.WithRunDir(filepath.Join(tmpdir, "rundir")), + certificate.WithShareDir(filepath.Join(tmpdir, "sharedir")), + certificate.WithCertAutoenrollCmd(autoenrollCmd), + ) + + err := m.ApplyPolicy(context.Background(), "keypress", !tc.isUser, !tc.isOffline, tc.entries) + if tc.wantErr { + require.Error(t, err, "ApplyPolicy should fail") + } else { + require.NoError(t, err, "ApplyPolicy should succeed") + } + + // Check that the autoenroll script was called with the expected arguments + // and that the output file was created + if !tc.runScript { + return + } + + got, err := os.ReadFile(autoenrollCmdOutputFile) + require.NoError(t, err, "Setup: Autoenroll mock output should be readable") + + want := testutils.LoadWithUpdateFromGolden(t, string(got)) + require.Equal(t, want, string(got), "Unexpected output from autoenroll mock") + }) + } +} + +func mockAutoenrollScript(t *testing.T, scriptOutputFile string, autoenrollScriptError bool) []string { + t.Helper() + + cmdArgs := []string{"env", "GO_WANT_HELPER_PROCESS=1", os.Args[0], "-test.run=TestMockAutoenrollScript", "--", scriptOutputFile} + if autoenrollScriptError { + cmdArgs = append(cmdArgs, "-Exit1-") + } + + return cmdArgs +} + +func TestMockAutoenrollScript(t *testing.T) { + if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { + return + } + defer os.Exit(0) + + var outputFile string + + args := os.Args + for len(args) > 0 { + if args[0] == "--" { + outputFile = args[1] + args = args[2:] + break + } + args = args[1:] + } + + if args[0] == "-Exit1-" { + fmt.Fprintf(os.Stderr, "EXIT 1 requested in mock") + os.Exit(1) + } + + dataToWrite := strings.Join(args, " ") + "\n" + dataToWrite += "KRB5CCNAME=" + os.Getenv("KRB5CCNAME") + "\n" + dataToWrite += "PYTHONPATH=" + os.Getenv("PYTHONPATH") + "\n" + + // Replace tmpdir with a placeholder to avoid non-deterministic test failures + tmpdir := filepath.Dir(outputFile) + dataToWrite = strings.ReplaceAll(dataToWrite, tmpdir, "#TMPDIR#") + + err := os.WriteFile(outputFile, []byte(dataToWrite), 0600) + require.NoError(t, err, "Setup: Can't write script args to output file") +} + +func TestMain(m *testing.M) { + testutils.InstallUpdateFlag() + flag.Parse() + + m.Run() + testutils.MergeCoverages() +} diff --git a/internal/policies/certificate/python/vendor_samba/gp/gp_cert_auto_enroll_ext.py b/internal/policies/certificate/python/vendor_samba/gp/gp_cert_auto_enroll_ext.py new file mode 100644 index 000000000..54be3bc28 --- /dev/null +++ b/internal/policies/certificate/python/vendor_samba/gp/gp_cert_auto_enroll_ext.py @@ -0,0 +1,536 @@ +# gp_cert_auto_enroll_ext samba group policy +# Copyright (C) David Mulder 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 . + +import os +import operator +import requests +from vendor_samba.gp.gpclass import gp_pol_ext, gp_applier, GPOSTATE +from samba import Ldb +from ldb import SCOPE_SUBTREE, SCOPE_BASE +from samba.auth import system_session +from vendor_samba.gp.gpclass import get_dc_hostname +import base64 +from shutil import which +from subprocess import Popen, PIPE +import re +import json +from vendor_samba.gp.util.logging import log +import struct +try: + from cryptography.hazmat.primitives.serialization.pkcs7 import \ + load_der_pkcs7_certificates +except ModuleNotFoundError: + def load_der_pkcs7_certificates(x): return [] + log.error('python cryptography missing pkcs7 support. ' + 'Certificate chain parsing will fail') +from cryptography.hazmat.primitives.serialization import Encoding +from cryptography.x509 import load_der_x509_certificate +from cryptography.hazmat.backends import default_backend +from samba.common import get_string + +cert_wrap = b""" +-----BEGIN CERTIFICATE----- +%s +-----END CERTIFICATE-----""" +endpoint_re = '(https|HTTPS)://(?P[a-zA-Z0-9.-]+)/ADPolicyProvider' + \ + '_CEP_(?P[a-zA-Z]+)/service.svc/CEP' + + +def octet_string_to_objectGUID(data): + """Convert an octet string to an objectGUID.""" + return '%s-%s-%s-%s-%s' % ('%02x' % struct.unpack('H', data[8:10])[0], + '%02x%02x' % struct.unpack('>HL', data[10:])) + + +def group_and_sort_end_point_information(end_point_information): + """Group and Sort End Point Information. + + [MS-CAESO] 4.4.5.3.2.3 + In this step autoenrollment processes the end point information by grouping + it by CEP ID and sorting in the order with which it will use the end point + to access the CEP information. + """ + # Create groups of the CertificateEnrollmentPolicyEndPoint instances that + # have the same value of the EndPoint.PolicyID datum. + end_point_groups = {} + for e in end_point_information: + if e['PolicyID'] not in end_point_groups.keys(): + end_point_groups[e['PolicyID']] = [] + end_point_groups[e['PolicyID']].append(e) + + # Sort each group by following these rules: + for end_point_group in end_point_groups.values(): + # Sort the CertificateEnrollmentPolicyEndPoint instances in ascending + # order based on the EndPoint.Cost value. + end_point_group.sort(key=lambda e: e['Cost']) + + # For instances that have the same EndPoint.Cost: + cost_list = [e['Cost'] for e in end_point_group] + costs = set(cost_list) + for cost in costs: + i = cost_list.index(cost) + j = len(cost_list)-operator.indexOf(reversed(cost_list), cost)-1 + if i == j: + continue + + # Sort those that have EndPoint.Authentication equal to Kerberos + # first. Then sort those that have EndPoint.Authentication equal to + # Anonymous. The rest of the CertificateEnrollmentPolicyEndPoint + # instances follow in an arbitrary order. + def sort_auth(e): + # 0x2 - Kerberos + if e['AuthFlags'] == 0x2: + return 0 + # 0x1 - Anonymous + elif e['AuthFlags'] == 0x1: + return 1 + else: + return 2 + end_point_group[i:j+1] = sorted(end_point_group[i:j+1], + key=sort_auth) + return list(end_point_groups.values()) + +def obtain_end_point_information(entries): + """Obtain End Point Information. + + [MS-CAESO] 4.4.5.3.2.2 + In this step autoenrollment initializes the + CertificateEnrollmentPolicyEndPoints table. + """ + end_point_information = {} + section = 'Software\\Policies\\Microsoft\\Cryptography\\PolicyServers\\' + for e in entries: + if not e.keyname.startswith(section): + continue + name = e.keyname.replace(section, '') + if name not in end_point_information.keys(): + end_point_information[name] = {} + end_point_information[name][e.valuename] = e.data + for ca in end_point_information.values(): + m = re.match(endpoint_re, ca['URL']) + if m: + name = '%s-CA' % m.group('server').replace('.', '-') + ca['name'] = name + ca['hostname'] = m.group('server') + ca['auth'] = m.group('auth') + elif ca['URL'].lower() != 'ldap:': + edata = { 'endpoint': ca['URL'] } + log.error('Failed to parse the endpoint', edata) + return {} + end_point_information = \ + group_and_sort_end_point_information(end_point_information.values()) + return end_point_information + +def fetch_certification_authorities(ldb): + """Initialize CAs. + + [MS-CAESO] 4.4.5.3.1.2 + """ + result = [] + basedn = ldb.get_default_basedn() + # Autoenrollment MUST do an LDAP search for the CA information + # (pKIEnrollmentService) objects under the following container: + dn = 'CN=Enrollment Services,CN=Public Key Services,CN=Services,CN=Configuration,%s' % basedn + attrs = ['cACertificate', 'cn', 'dNSHostName'] + expr = '(objectClass=pKIEnrollmentService)' + res = ldb.search(dn, SCOPE_SUBTREE, expr, attrs) + if len(res) == 0: + return result + for es in res: + data = { 'name': get_string(es['cn'][0]), + 'hostname': get_string(es['dNSHostName'][0]), + 'cACertificate': get_string(base64.b64encode(es['cACertificate'][0])) + } + result.append(data) + return result + +def fetch_template_attrs(ldb, name, attrs=None): + if attrs is None: + 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): + return cert_wrap % re.sub(b"(.{64})", b"\\1\n", cert.encode(), 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' + p = Popen([cepces_submit, '--server=%s' % server, '--auth=Kerberos'], + env=env, stdout=PIPE, stderr=PIPE) + out, err = p.communicate() + if p.returncode != 0: + data = { 'Error': err.decode() } + log.error('Failed to fetch the list of supported templates.', data) + return out.strip().split() + return [] + + +def getca(ca, url, trust_dir): + """Fetch Certificate Chain from the CA.""" + root_cert = os.path.join(trust_dir, '%s.crt' % ca['name']) + root_certs = [] + + try: + r = requests.get(url=url, params={'operation': 'GetCACert', + 'message': 'CAIdentifier'}) + except requests.exceptions.ConnectionError: + log.warn('Failed to establish a new connection') + r = None + if r is None or r.content == b'' or r.headers['Content-Type'] == 'text/html': + log.warn('Failed to fetch the root certificate chain.') + log.warn('The Network Device Enrollment Service is either not' + + ' installed or not configured.') + if 'cACertificate' in ca: + log.warn('Installing the server certificate only.') + try: + cert = load_der_x509_certificate(ca['cACertificate']) + except TypeError: + cert = load_der_x509_certificate(ca['cACertificate'], + default_backend()) + cert_data = cert.public_bytes(Encoding.PEM) + with open(root_cert, 'wb') as w: + w.write(cert_data) + root_certs.append(root_cert) + return root_certs + + if r.headers['Content-Type'] == 'application/x-x509-ca-cert': + # Older versions of load_der_x509_certificate require a backend param + try: + cert = load_der_x509_certificate(r.content) + except TypeError: + cert = load_der_x509_certificate(r.content, default_backend()) + cert_data = cert.public_bytes(Encoding.PEM) + with open(root_cert, 'wb') as w: + w.write(cert_data) + root_certs.append(root_cert) + elif r.headers['Content-Type'] == 'application/x-x509-ca-ra-cert': + certs = load_der_pkcs7_certificates(r.content) + for i in range(0, len(certs)): + cert = certs[i].public_bytes(Encoding.PEM) + filename, extension = root_cert.rsplit('.', 1) + dest = '%s.%d.%s' % (filename, i, extension) + with open(dest, 'wb') as w: + w.write(cert) + root_certs.append(dest) + else: + log.warn('getca: Wrong (or missing) MIME content type') + + return root_certs + + +def cert_enroll(ca, ldb, trust_dir, private_dir, global_trust_dir, auth='Kerberos'): + """Install the root certificate chain.""" + data = dict({'files': [], 'templates': []}, **ca) + url = 'http://%s/CertSrv/mscep/mscep.dll/pkiclient.exe?' % ca['hostname'] + root_certs = getca(ca, url, trust_dir) + 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: + log.warn('Failed to symlink root certificate to the' + ' admin trust anchors') + except FileNotFoundError: + log.warn('Failed to symlink root certificate to the' + ' admin trust anchors.' + ' The directory was not found', global_trust_dir) + except FileExistsError: + # If we're simply downloading a renewed cert, the symlink + # already exists. Ignore the FileExistsError. Preserve the + # existing symlink in the unapply data. + data['files'].append(dst) + 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): + p = Popen([getcert, 'add-ca', '-c', ca['name'], '-e', + '%s --server=%s --auth=%s' % (cepces_submit, + ca['hostname'], auth)], + stdout=PIPE, stderr=PIPE) + out, err = p.communicate() + log.debug(out.decode()) + if p.returncode != 0: + data = { 'Error': err.decode(), 'CA': ca['name'] } + log.error('Failed to add Certificate Authority', data) + supported_templates = get_supported_templates(ca['hostname']) + for template in supported_templates: + attrs = fetch_template_attrs(ldb, template) + nickname = '%s.%s' % (ca['name'], template.decode()) + keyfile = os.path.join(private_dir, '%s.key' % nickname) + certfile = os.path.join(trust_dir, '%s.crt' % nickname) + p = Popen([getcert, 'request', '-c', ca['name'], + '-T', template.decode(), + '-I', nickname, '-k', keyfile, '-f', certfile, + '-g', attrs['msPKI-Minimal-Key-Size'][0]], + stdout=PIPE, stderr=PIPE) + out, err = p.communicate() + log.debug(out.decode()) + if p.returncode != 0: + data = { 'Error': err.decode(), 'Certificate': nickname } + log.error('Failed to request certificate', data) + data['files'].extend([keyfile, certfile]) + data['templates'].append(nickname) + if update is not None: + Popen([update]).wait() + else: + log.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, gp_applier): + def __str__(self): + return 'Cryptography\AutoEnrollment' + + def unapply(self, guid, attribute, value): + ca_cn = base64.b64decode(attribute) + data = json.loads(value) + 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): + if os.path.exists(f): + os.unlink(f) + self.cache_remove_attribute(guid, attribute) + + def apply(self, guid, ca, applier_func, *args, **kwargs): + attribute = base64.b64encode(ca['name'].encode()).decode() + # If the policy has changed, unapply, then apply new policy + old_val = self.cache_get_attribute_value(guid, attribute) + old_data = json.loads(old_val) if old_val is not None else {} + templates = ['%s.%s' % (ca['name'], t.decode()) for t in get_supported_templates(ca['hostname'])] + new_data = { 'templates': templates, **ca } + if any((new_data[k] != old_data[k] if k in old_data else False) \ + for k in new_data.keys()) or \ + self.cache_get_apply_state() == GPOSTATE.ENFORCE: + self.unapply(guid, attribute, old_val) + # If policy is already applied, skip application + if old_val is not None and \ + self.cache_get_apply_state() != GPOSTATE.ENFORCE: + return + + # Apply the policy and log the changes + data = applier_func(*args, **kwargs) + self.cache_add_attribute(guid, attribute, data) + + def process_group_policy(self, deleted_gpo_list, changed_gpo_list, + trust_dir=None, private_dir=None, global_trust_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 global_trust_dir is None: + global_trust_dir = '/etc/pki/trust/anchors' + 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: + if str(self) in settings: + for ca_cn_enc, data in settings[str(self)].items(): + self.unapply(guid, ca_cn_enc, data) + + for gpo in changed_gpo_list: + if gpo.file_sys_path: + section = 'Software\Policies\Microsoft\Cryptography\AutoEnrollment' + 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 == 0x1 + manage = e.data & 0x2 == 0x2 + retrive_pending = e.data & 0x4 == 0x4 + if enroll: + ca_names = self.__enroll(gpo.name, + pol_conf.entries, + trust_dir, private_dir, + global_trust_dir) + + # Cleanup any old CAs that have been removed + ca_attrs = [base64.b64encode(n.encode()).decode() \ + for n in ca_names] + self.clean(gpo.name, keep=ca_attrs) + else: + # If enrollment has been disabled for this GPO, + # remove any existing policy + ca_attrs = \ + self.cache_get_all_attribute_values(gpo.name) + self.clean(gpo.name, remove=list(ca_attrs.keys())) + + def __read_cep_data(self, guid, ldb, end_point_information, + trust_dir, private_dir, global_trust_dir): + """Read CEP Data. + + [MS-CAESO] 4.4.5.3.2.4 + In this step autoenrollment initializes instances of the + CertificateEnrollmentPolicy by accessing end points associated with CEP + groups created in the previous step. + """ + # For each group created in the previous step: + for end_point_group in end_point_information: + # Pick an arbitrary instance of the + # CertificateEnrollmentPolicyEndPoint from the group + e = end_point_group[0] + + # If this instance does not have the AutoEnrollmentEnabled flag set + # in the EndPoint.Flags, continue with the next group. + if not e['Flags'] & 0x10: + continue + + # If the current group contains a + # CertificateEnrollmentPolicyEndPoint instance with EndPoint.URI + # equal to "LDAP": + if any([e['URL'] == 'LDAP:' for e in end_point_group]): + # Perform an LDAP search to read the value of the objectGuid + # attribute of the root object of the forest root domain NC. If + # any errors are encountered, continue with the next group. + res = ldb.search('', SCOPE_BASE, '(objectClass=*)', + ['rootDomainNamingContext']) + if len(res) != 1: + continue + res2 = ldb.search(res[0]['rootDomainNamingContext'][0], + SCOPE_BASE, '(objectClass=*)', + ['objectGUID']) + if len(res2) != 1: + continue + + # Compare the value read in the previous step to the + # EndPoint.PolicyId datum CertificateEnrollmentPolicyEndPoint + # instance. If the values do not match, continue with the next + # group. + objectGUID = '{%s}' % \ + octet_string_to_objectGUID(res2[0]['objectGUID'][0]).upper() + if objectGUID != e['PolicyID']: + continue + + # For each CertificateEnrollmentPolicyEndPoint instance for that + # group: + ca_names = [] + for ca in end_point_group: + # If EndPoint.URI equals "LDAP": + if ca['URL'] == 'LDAP:': + # This is a basic configuration. + cas = fetch_certification_authorities(ldb) + for _ca in cas: + self.apply(guid, _ca, cert_enroll, _ca, ldb, trust_dir, + private_dir, global_trust_dir) + ca_names.append(_ca['name']) + # If EndPoint.URI starts with "HTTPS//": + elif ca['URL'].lower().startswith('https://'): + self.apply(guid, ca, cert_enroll, ca, ldb, trust_dir, + private_dir, global_trust_dir, auth=ca['auth']) + ca_names.append(ca['name']) + else: + edata = { 'endpoint': ca['URL'] } + log.error('Unrecognized endpoint', edata) + return ca_names + + def __enroll(self, guid, entries, trust_dir, private_dir, global_trust_dir): + url = 'ldap://%s' % get_dc_hostname(self.creds, self.lp) + ldb = Ldb(url=url, session_info=system_session(), + lp=self.lp, credentials=self.creds) + + ca_names = [] + end_point_information = obtain_end_point_information(entries) + if len(end_point_information) > 0: + ca_names.extend(self.__read_cep_data(guid, ldb, + end_point_information, + trust_dir, private_dir, global_trust_dir)) + else: + cas = fetch_certification_authorities(ldb) + for ca in cas: + self.apply(guid, ca, cert_enroll, ca, ldb, trust_dir, + private_dir, global_trust_dir) + ca_names.append(ca['name']) + return ca_names + + 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 == 0x1 + 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) + end_point_information = \ + obtain_end_point_information(pol_conf.entries) + cas = fetch_certification_authorities(ldb) + if len(end_point_information) > 0: + cas2 = [ep for sl in end_point_information for ep in sl] + if any([ca['URL'] == 'LDAP:' for ca in cas2]): + cas.extend(cas2) + else: + cas = cas2 + for ca in cas: + if 'URL' in ca and ca['URL'] == 'LDAP:': + continue + policy = 'Auto Enrollment Policy' + cn = ca['name'] + if policy not in output: + output[policy] = {} + output[policy][cn] = {} + if 'cACertificate' in ca: + output[policy][cn]['CA Certificate'] = \ + format_root_cert(ca['cACertificate']).decode() + output[policy][cn]['Auto Enrollment Server'] = \ + ca['hostname'] + supported_templates = \ + get_supported_templates(ca['hostname']) + output[policy][cn]['Templates'] = \ + [t.decode() for t in supported_templates] + return output diff --git a/internal/policies/certificate/python/vendor_samba/gp/gpclass.py b/internal/policies/certificate/python/vendor_samba/gp/gpclass.py new file mode 100644 index 000000000..0ef86576d --- /dev/null +++ b/internal/policies/certificate/python/vendor_samba/gp/gpclass.py @@ -0,0 +1,884 @@ +# Reads important GPO parameters and updates Samba +# Copyright (C) Luke Morrison 2013 +# +# 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 . + + +import sys +import os, shutil +import errno +import tdb +import pwd +sys.path.insert(0, "bin/python") +from samba import NTSTATUSError +from configparser import ConfigParser +from io import StringIO +import traceback +from samba.common import get_bytes +from abc import ABCMeta, abstractmethod +import xml.etree.ElementTree as etree +import re +from samba.net import Net +from samba.dcerpc import nbt +from samba.samba3 import libsmb_samba_internal as libsmb +import samba.gpo as gpo +from samba.param import LoadParm +from uuid import UUID +from tempfile import NamedTemporaryFile +from samba.dcerpc import preg +from samba.dcerpc import misc +from samba.ndr import ndr_pack, ndr_unpack +from samba.credentials import SMB_SIGNING_REQUIRED +from vendor_samba.gp.util.logging import log +from hashlib import blake2b +import numbers +from samba.common import get_string + +try: + from enum import Enum + GPOSTATE = Enum('GPOSTATE', 'APPLY ENFORCE UNAPPLY') +except ImportError: + class GPOSTATE: + APPLY = 1 + ENFORCE = 2 + UNAPPLY = 3 + + +class gp_log: + ''' Log settings overwritten by gpo apply + The gp_log is an xml file that stores a history of gpo changes (and the + original setting value). + + The log is organized like so: + + + + + + + + + -864000000000 + -36288000000000 + 7 + 1 + + + 1d + + 300 + + + + + + Each guid value contains a list of extensions, which contain a list of + attributes. The guid value represents a GPO. The attributes are the values + of those settings prior to the application of the GPO. + The list of guids is enclosed within a user name, which represents the user + the settings were applied to. This user may be the samaccountname of the + local computer, which implies that these are machine policies. + The applylog keeps track of the order in which the GPOs were applied, so + that they can be rolled back in reverse, returning the machine to the state + prior to policy application. + ''' + def __init__(self, user, gpostore, db_log=None): + ''' Initialize the gp_log + param user - the username (or machine name) that policies are + being applied to + param gpostore - the GPOStorage obj which references the tdb which + contains gp_logs + param db_log - (optional) a string to initialize the gp_log + ''' + self._state = GPOSTATE.APPLY + self.gpostore = gpostore + self.username = user + if db_log: + self.gpdb = etree.fromstring(db_log) + else: + self.gpdb = etree.Element('gp') + self.user = user + user_obj = self.gpdb.find('user[@name="%s"]' % user) + if user_obj is None: + user_obj = etree.SubElement(self.gpdb, 'user') + user_obj.attrib['name'] = user + + def state(self, value): + ''' Policy application state + param value - APPLY, ENFORCE, or UNAPPLY + + The behavior of the gp_log depends on whether we are applying policy, + enforcing policy, or unapplying policy. During an apply, old settings + are recorded in the log. During an enforce, settings are being applied + but the gp_log does not change. During an unapply, additions to the log + should be ignored (since function calls to apply settings are actually + reverting policy), but removals from the log are allowed. + ''' + # If we're enforcing, but we've unapplied, apply instead + if value == GPOSTATE.ENFORCE: + user_obj = self.gpdb.find('user[@name="%s"]' % self.user) + apply_log = user_obj.find('applylog') + if apply_log is None or len(apply_log) == 0: + self._state = GPOSTATE.APPLY + else: + self._state = value + else: + self._state = value + + def get_state(self): + '''Check the GPOSTATE + ''' + return self._state + + def set_guid(self, guid): + ''' Log to a different GPO guid + param guid - guid value of the GPO from which we're applying + policy + ''' + self.guid = guid + user_obj = self.gpdb.find('user[@name="%s"]' % self.user) + obj = user_obj.find('guid[@value="%s"]' % guid) + if obj is None: + obj = etree.SubElement(user_obj, 'guid') + obj.attrib['value'] = guid + if self._state == GPOSTATE.APPLY: + apply_log = user_obj.find('applylog') + if apply_log is None: + apply_log = etree.SubElement(user_obj, 'applylog') + prev = apply_log.find('guid[@value="%s"]' % guid) + if prev is None: + item = etree.SubElement(apply_log, 'guid') + item.attrib['count'] = '%d' % (len(apply_log) - 1) + item.attrib['value'] = guid + + def store(self, gp_ext_name, attribute, old_val): + ''' Store an attribute in the gp_log + param gp_ext_name - Name of the extension applying policy + param attribute - The attribute being modified + param old_val - The value of the attribute prior to policy + application + ''' + if self._state == GPOSTATE.UNAPPLY or self._state == GPOSTATE.ENFORCE: + return None + user_obj = self.gpdb.find('user[@name="%s"]' % self.user) + guid_obj = user_obj.find('guid[@value="%s"]' % self.guid) + assert guid_obj is not None, "gpo guid was not set" + ext = guid_obj.find('gp_ext[@name="%s"]' % gp_ext_name) + if ext is None: + ext = etree.SubElement(guid_obj, 'gp_ext') + ext.attrib['name'] = gp_ext_name + attr = ext.find('attribute[@name="%s"]' % attribute) + if attr is None: + attr = etree.SubElement(ext, 'attribute') + attr.attrib['name'] = attribute + attr.text = old_val + + def retrieve(self, gp_ext_name, attribute): + ''' Retrieve a stored attribute from the gp_log + param gp_ext_name - Name of the extension which applied policy + param attribute - The attribute being retrieved + return - The value of the attribute prior to policy + application + ''' + user_obj = self.gpdb.find('user[@name="%s"]' % self.user) + guid_obj = user_obj.find('guid[@value="%s"]' % self.guid) + assert guid_obj is not None, "gpo guid was not set" + ext = guid_obj.find('gp_ext[@name="%s"]' % gp_ext_name) + if ext is not None: + attr = ext.find('attribute[@name="%s"]' % attribute) + if attr is not None: + return attr.text + return None + + def retrieve_all(self, gp_ext_name): + ''' Retrieve all stored attributes for this user, GPO guid, and CSE + param gp_ext_name - Name of the extension which applied policy + return - The values of the attributes prior to policy + application + ''' + user_obj = self.gpdb.find('user[@name="%s"]' % self.user) + guid_obj = user_obj.find('guid[@value="%s"]' % self.guid) + assert guid_obj is not None, "gpo guid was not set" + ext = guid_obj.find('gp_ext[@name="%s"]' % gp_ext_name) + if ext is not None: + attrs = ext.findall('attribute') + return {attr.attrib['name']: attr.text for attr in attrs} + return {} + + def get_applied_guids(self): + ''' Return a list of applied ext guids + return - List of guids for gpos that have applied settings + to the system. + ''' + guids = [] + user_obj = self.gpdb.find('user[@name="%s"]' % self.user) + if user_obj is not None: + apply_log = user_obj.find('applylog') + if apply_log is not None: + guid_objs = apply_log.findall('guid[@count]') + guids_by_count = [(g.get('count'), g.get('value')) + for g in guid_objs] + guids_by_count.sort(reverse=True) + guids.extend(guid for count, guid in guids_by_count) + return guids + + def get_applied_settings(self, guids): + ''' Return a list of applied ext guids + return - List of tuples containing the guid of a gpo, then + a dictionary of policies and their values prior + policy application. These are sorted so that the + most recently applied settings are removed first. + ''' + ret = [] + user_obj = self.gpdb.find('user[@name="%s"]' % self.user) + for guid in guids: + guid_settings = user_obj.find('guid[@value="%s"]' % guid) + exts = guid_settings.findall('gp_ext') + settings = {} + for ext in exts: + attr_dict = {} + attrs = ext.findall('attribute') + for attr in attrs: + attr_dict[attr.attrib['name']] = attr.text + settings[ext.attrib['name']] = attr_dict + ret.append((guid, settings)) + return ret + + def delete(self, gp_ext_name, attribute): + ''' Remove an attribute from the gp_log + param gp_ext_name - name of extension from which to remove the + attribute + param attribute - attribute to remove + ''' + user_obj = self.gpdb.find('user[@name="%s"]' % self.user) + guid_obj = user_obj.find('guid[@value="%s"]' % self.guid) + assert guid_obj is not None, "gpo guid was not set" + ext = guid_obj.find('gp_ext[@name="%s"]' % gp_ext_name) + if ext is not None: + attr = ext.find('attribute[@name="%s"]' % attribute) + if attr is not None: + ext.remove(attr) + if len(ext) == 0: + guid_obj.remove(ext) + + def commit(self): + ''' Write gp_log changes to disk ''' + self.gpostore.store(self.username, etree.tostring(self.gpdb, 'utf-8')) + + +class GPOStorage: + def __init__(self, log_file): + if os.path.isfile(log_file): + self.log = tdb.open(log_file) + else: + self.log = tdb.Tdb(log_file, 0, tdb.DEFAULT, os.O_CREAT | os.O_RDWR) + + def start(self): + self.log.transaction_start() + + def get_int(self, key): + try: + return int(self.log.get(get_bytes(key))) + except TypeError: + return None + + def get(self, key): + return self.log.get(get_bytes(key)) + + def get_gplog(self, user): + return gp_log(user, self, self.log.get(get_bytes(user))) + + def store(self, key, val): + self.log.store(get_bytes(key), get_bytes(val)) + + def cancel(self): + self.log.transaction_cancel() + + def delete(self, key): + self.log.delete(get_bytes(key)) + + def commit(self): + self.log.transaction_commit() + + def __del__(self): + self.log.close() + + +class gp_ext(object): + __metaclass__ = ABCMeta + + def __init__(self, lp, creds, username, store): + self.lp = lp + self.creds = creds + self.username = username + self.gp_db = store.get_gplog(username) + + @abstractmethod + def process_group_policy(self, deleted_gpo_list, changed_gpo_list): + pass + + @abstractmethod + def read(self, policy): + pass + + def parse(self, afile): + local_path = self.lp.cache_path('gpo_cache') + data_file = os.path.join(local_path, check_safe_path(afile).upper()) + if os.path.exists(data_file): + return self.read(data_file) + return None + + @abstractmethod + def __str__(self): + pass + + @abstractmethod + def rsop(self, gpo): + return {} + + +class gp_inf_ext(gp_ext): + def read(self, data_file): + policy = open(data_file, 'rb').read() + inf_conf = ConfigParser(interpolation=None) + inf_conf.optionxform = str + try: + inf_conf.readfp(StringIO(policy.decode())) + except UnicodeDecodeError: + inf_conf.readfp(StringIO(policy.decode('utf-16'))) + return inf_conf + + +class gp_pol_ext(gp_ext): + def read(self, data_file): + raw = open(data_file, 'rb').read() + return ndr_unpack(preg.file, raw) + + +class gp_xml_ext(gp_ext): + def read(self, data_file): + raw = open(data_file, 'rb').read() + try: + return etree.fromstring(raw.decode()) + except UnicodeDecodeError: + return etree.fromstring(raw.decode('utf-16')) + + +class gp_applier(object): + '''Group Policy Applier/Unapplier/Modifier + The applier defines functions for monitoring policy application, + removal, and modification. It must be a multi-derived class paired + with a subclass of gp_ext. + ''' + __metaclass__ = ABCMeta + + def cache_add_attribute(self, guid, attribute, value): + '''Add an attribute and value to the Group Policy cache + guid - The GPO guid which applies this policy + attribute - The attribute name of the policy being applied + value - The value of the policy being applied + + Normally called by the subclass apply() function after applying policy. + ''' + self.gp_db.set_guid(guid) + self.gp_db.store(str(self), attribute, value) + self.gp_db.commit() + + def cache_remove_attribute(self, guid, attribute): + '''Remove an attribute from the Group Policy cache + guid - The GPO guid which applies this policy + attribute - The attribute name of the policy being unapplied + + Normally called by the subclass unapply() function when removing old + policy. + ''' + self.gp_db.set_guid(guid) + self.gp_db.delete(str(self), attribute) + self.gp_db.commit() + + def cache_get_attribute_value(self, guid, attribute): + '''Retrieve the value stored in the cache for the given attribute + guid - The GPO guid which applies this policy + attribute - The attribute name of the policy + ''' + self.gp_db.set_guid(guid) + return self.gp_db.retrieve(str(self), attribute) + + def cache_get_all_attribute_values(self, guid): + '''Retrieve all attribute/values currently stored for this gpo+policy + guid - The GPO guid which applies this policy + ''' + self.gp_db.set_guid(guid) + return self.gp_db.retrieve_all(str(self)) + + def cache_get_apply_state(self): + '''Return the current apply state + return - APPLY|ENFORCE|UNAPPLY + ''' + return self.gp_db.get_state() + + def generate_attribute(self, name, *args): + '''Generate an attribute name from arbitrary data + name - A name to ensure uniqueness + args - Any arbitrary set of args, str or bytes + return - A blake2b digest of the data, the attribute + + The importance here is the digest of the data makes the attribute + reproducible and uniquely identifies it. Hashing the name with + the data ensures we don't falsly identify a match which is the same + text in a different file. Using this attribute generator is optional. + ''' + data = b''.join([get_bytes(arg) for arg in [*args]]) + return blake2b(get_bytes(name)+data).hexdigest() + + def generate_value_hash(self, *args): + '''Generate a unique value which identifies value changes + args - Any arbitrary set of args, str or bytes + return - A blake2b digest of the data, the value represented + ''' + data = b''.join([get_bytes(arg) for arg in [*args]]) + return blake2b(data).hexdigest() + + @abstractmethod + def unapply(self, guid, attribute, value): + '''Group Policy Unapply + guid - The GPO guid which applies this policy + attribute - The attribute name of the policy being unapplied + value - The value of the policy being unapplied + ''' + pass + + @abstractmethod + def apply(self, guid, attribute, applier_func, *args): + '''Group Policy Apply + guid - The GPO guid which applies this policy + attribute - The attribute name of the policy being applied + applier_func - An applier function which takes variable args + args - The variable arguments to pass to applier_func + + The applier_func function MUST return the value of the policy being + applied. It's important that implementations of `apply` check for and + first unapply any changed policy. See for example calls to + `cache_get_all_attribute_values()` which searches for all policies + applied by this GPO for this Client Side Extension (CSE). + ''' + pass + + def clean(self, guid, keep=None, remove=None, **kwargs): + '''Cleanup old removed attributes + keep - A list of attributes to keep + remove - A single attribute to remove, or a list of attributes to + remove + kwargs - Additional keyword args required by the subclass unapply + function + + This is only necessary for CSEs which provide multiple attributes. + ''' + # Clean syntax is, either provide a single remove attribute, + # or a list of either removal attributes or keep attributes. + if keep is None: + keep = [] + if remove is None: + remove = [] + + if type(remove) != list: + value = self.cache_get_attribute_value(guid, remove) + if value is not None: + self.unapply(guid, remove, value, **kwargs) + else: + old_vals = self.cache_get_all_attribute_values(guid) + for attribute, value in old_vals.items(): + if (len(remove) > 0 and attribute in remove) or \ + (len(keep) > 0 and attribute not in keep): + self.unapply(guid, attribute, value, **kwargs) + + +class gp_file_applier(gp_applier): + '''Group Policy File Applier/Unapplier/Modifier + Subclass of abstract class gp_applier for monitoring policy applied + via a file. + ''' + + def __generate_value(self, value_hash, files, sep): + data = [value_hash] + data.extend(files) + return sep.join(data) + + def __parse_value(self, value, sep): + '''Parse a value + return - A unique HASH, followed by the file list + ''' + if value is None: + return None, [] + data = value.split(sep) + if '/' in data[0]: + # The first element is not a hash, but a filename. This is a + # legacy value. + return None, data + else: + return data[0], data[1:] if len(data) > 1 else [] + + def unapply(self, guid, attribute, files, sep=':'): + # If the value isn't a list of files, parse value from the log + if type(files) != list: + _, files = self.__parse_value(files, sep) + for file in files: + if os.path.exists(file): + os.unlink(file) + self.cache_remove_attribute(guid, attribute) + + def apply(self, guid, attribute, value_hash, applier_func, *args, sep=':'): + ''' + applier_func MUST return a list of files created by the applier. + + This applier is for policies which only apply to a single file (with + a couple small exceptions). This applier will remove any policy applied + by this GPO which doesn't match the new policy. + ''' + # If the policy has changed, unapply, then apply new policy + old_val = self.cache_get_attribute_value(guid, attribute) + # Ignore removal if this policy is applied and hasn't changed + old_val_hash, old_val_files = self.__parse_value(old_val, sep) + if (old_val_hash != value_hash or \ + self.cache_get_apply_state() == GPOSTATE.ENFORCE) or \ + not all([os.path.exists(f) for f in old_val_files]): + self.unapply(guid, attribute, old_val_files) + else: + # If policy is already applied, skip application + return + + # Apply the policy and log the changes + files = applier_func(*args) + new_value = self.__generate_value(value_hash, files, sep) + self.cache_add_attribute(guid, attribute, new_value) + + +''' Fetch the hostname of a writable DC ''' + + +def get_dc_hostname(creds, lp): + net = Net(creds=creds, lp=lp) + cldap_ret = net.finddc(domain=lp.get('realm'), flags=(nbt.NBT_SERVER_LDAP | + nbt.NBT_SERVER_DS)) + return cldap_ret.pdc_dns_name + + +''' Fetch a list of GUIDs for applicable GPOs ''' + + +def get_gpo_list(dc_hostname, creds, lp, username): + gpos = [] + ads = gpo.ADS_STRUCT(dc_hostname, lp, creds) + if ads.connect(): + # username is DOM\\SAM, but get_gpo_list expects SAM + gpos = ads.get_gpo_list(username.split('\\')[-1]) + return gpos + + +def cache_gpo_dir(conn, cache, sub_dir): + loc_sub_dir = sub_dir.upper() + local_dir = os.path.join(cache, loc_sub_dir) + try: + os.makedirs(local_dir, mode=0o755) + except OSError as e: + if e.errno != errno.EEXIST: + raise + for fdata in conn.list(sub_dir): + if fdata['attrib'] & libsmb.FILE_ATTRIBUTE_DIRECTORY: + cache_gpo_dir(conn, cache, os.path.join(sub_dir, fdata['name'])) + else: + local_name = fdata['name'].upper() + f = NamedTemporaryFile(delete=False, dir=local_dir) + fname = os.path.join(sub_dir, fdata['name']).replace('/', '\\') + f.write(conn.loadfile(fname)) + f.close() + os.rename(f.name, os.path.join(local_dir, local_name)) + + +def check_safe_path(path): + dirs = re.split('/|\\\\', path) + if 'sysvol' in path.lower(): + ldirs = re.split('/|\\\\', path.lower()) + dirs = dirs[ldirs.index('sysvol') + 1:] + if '..' not in dirs: + return os.path.join(*dirs) + raise OSError(path) + + +def check_refresh_gpo_list(dc_hostname, lp, creds, gpos): + # Force signing for the connection + saved_signing_state = creds.get_smb_signing() + creds.set_smb_signing(SMB_SIGNING_REQUIRED) + conn = libsmb.Conn(dc_hostname, 'sysvol', lp=lp, creds=creds) + # Reset signing state + creds.set_smb_signing(saved_signing_state) + cache_path = lp.cache_path('gpo_cache') + for gpo_obj in gpos: + if not gpo_obj.file_sys_path: + continue + cache_gpo_dir(conn, cache_path, check_safe_path(gpo_obj.file_sys_path)) + + +def get_deleted_gpos_list(gp_db, gpos): + applied_gpos = gp_db.get_applied_guids() + current_guids = set([p.name for p in gpos]) + deleted_gpos = [guid for guid in applied_gpos if guid not in current_guids] + return gp_db.get_applied_settings(deleted_gpos) + +def gpo_version(lp, path): + # gpo.gpo_get_sysvol_gpt_version() reads the GPT.INI from a local file, + # read from the gpo client cache. + gpt_path = lp.cache_path(os.path.join('gpo_cache', path)) + return int(gpo.gpo_get_sysvol_gpt_version(gpt_path)[1]) + + +def apply_gp(lp, creds, store, gp_extensions, username, target, force=False): + gp_db = store.get_gplog(username) + dc_hostname = get_dc_hostname(creds, lp) + gpos = get_gpo_list(dc_hostname, creds, lp, username) + del_gpos = get_deleted_gpos_list(gp_db, gpos) + try: + check_refresh_gpo_list(dc_hostname, lp, creds, gpos) + except: + log.error('Failed downloading gpt cache from \'%s\' using SMB' + % dc_hostname) + return + + if force: + changed_gpos = gpos + gp_db.state(GPOSTATE.ENFORCE) + else: + changed_gpos = [] + for gpo_obj in gpos: + if not gpo_obj.file_sys_path: + continue + guid = gpo_obj.name + path = check_safe_path(gpo_obj.file_sys_path).upper() + version = gpo_version(lp, path) + if version != store.get_int(guid): + log.info('GPO %s has changed' % guid) + changed_gpos.append(gpo_obj) + gp_db.state(GPOSTATE.APPLY) + + store.start() + for ext in gp_extensions: + try: + ext = ext(lp, creds, username, store) + if target == 'Computer': + ext.process_group_policy(del_gpos, changed_gpos) + else: + drop_privileges(creds.get_principal(), ext.process_group_policy, + del_gpos, changed_gpos) + except Exception as e: + log.error('Failed to apply extension %s' % str(ext)) + _, _, tb = sys.exc_info() + filename, line_number, _, _ = traceback.extract_tb(tb)[-1] + log.error('%s:%d: %s: %s' % (filename, line_number, + type(e).__name__, str(e))) + continue + for gpo_obj in gpos: + if not gpo_obj.file_sys_path: + continue + guid = gpo_obj.name + path = check_safe_path(gpo_obj.file_sys_path).upper() + version = gpo_version(lp, path) + store.store(guid, '%i' % version) + store.commit() + + +def unapply_gp(lp, creds, store, gp_extensions, username, target): + gp_db = store.get_gplog(username) + gp_db.state(GPOSTATE.UNAPPLY) + # Treat all applied gpos as deleted + del_gpos = gp_db.get_applied_settings(gp_db.get_applied_guids()) + store.start() + for ext in gp_extensions: + try: + ext = ext(lp, creds, username, store) + if target == 'Computer': + ext.process_group_policy(del_gpos, []) + else: + drop_privileges(username, ext.process_group_policy, + del_gpos, []) + except Exception as e: + log.error('Failed to unapply extension %s' % str(ext)) + log.error('Message was: ' + str(e)) + continue + store.commit() + + +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' + '\n'.join(ret) + elif type(vals) == list: + ret = [' '*level + '[ %s ]' % __rsop_vals(v, level+2) for v in vals] + return '\n' + '\n'.join(ret) + else: + if isinstance(vals, numbers.Number): + return ' '*(level+2) + str(vals) + else: + return ' '*(level+2) + get_string(vals) + +def rsop(lp, creds, store, gp_extensions, username, target): + dc_hostname = get_dc_hostname(creds, lp) + gpos = get_gpo_list(dc_hostname, creds, lp, username) + check_refresh_gpo_list(dc_hostname, lp, creds, gpos) + + print('Resultant Set of Policy') + print('%s Policy\n' % target) + term_width = shutil.get_terminal_size(fallback=(120, 50))[0] + for gpo_obj in gpos: + if gpo_obj.display_name.strip() == 'Local Policy': + continue # We never apply local policy + print('GPO: %s' % gpo_obj.display_name) + print('='*term_width) + for ext in gp_extensions: + ext = ext(lp, creds, username, store) + cse_name_m = re.findall(r"'([\w\.]+)'", str(type(ext))) + if len(cse_name_m) > 0: + cse_name = cse_name_m[-1].split('.')[-1] + else: + cse_name = ext.__module__.split('.')[-1] + print(' CSE: %s' % cse_name) + print(' ' + ('-'*int(term_width/2))) + for section, settings in ext.rsop(gpo_obj).items(): + print(' Policy Type: %s' % section) + print(' ' + ('-'*int(term_width/2))) + print(__rsop_vals(settings).lstrip('\n')) + print(' ' + ('-'*int(term_width/2))) + print(' ' + ('-'*int(term_width/2))) + print('%s\n' % ('='*term_width)) + + +def parse_gpext_conf(smb_conf): + from samba.samba3 import param as s3param + lp = s3param.get_context() + if smb_conf is not None: + lp.load(smb_conf) + else: + lp.load_default() + ext_conf = lp.state_path('gpext.conf') + parser = ConfigParser(interpolation=None) + parser.read(ext_conf) + return lp, parser + + +def atomic_write_conf(lp, parser): + ext_conf = lp.state_path('gpext.conf') + with NamedTemporaryFile(mode="w+", delete=False, dir=os.path.dirname(ext_conf)) as f: + parser.write(f) + os.rename(f.name, ext_conf) + + +def check_guid(guid): + # Check for valid guid with curly braces + if guid[0] != '{' or guid[-1] != '}' or len(guid) != 38: + return False + try: + UUID(guid, version=4) + except ValueError: + return False + return True + + +def register_gp_extension(guid, name, path, + smb_conf=None, machine=True, user=True): + # Check that the module exists + if not os.path.exists(path): + return False + if not check_guid(guid): + return False + + lp, parser = parse_gpext_conf(smb_conf) + if guid not in parser.sections(): + parser.add_section(guid) + parser.set(guid, 'DllName', path) + parser.set(guid, 'ProcessGroupPolicy', name) + parser.set(guid, 'NoMachinePolicy', "0" if machine else "1") + parser.set(guid, 'NoUserPolicy', "0" if user else "1") + + atomic_write_conf(lp, parser) + + return True + + +def list_gp_extensions(smb_conf=None): + _, parser = parse_gpext_conf(smb_conf) + results = {} + for guid in parser.sections(): + results[guid] = {} + results[guid]['DllName'] = parser.get(guid, 'DllName') + results[guid]['ProcessGroupPolicy'] = \ + parser.get(guid, 'ProcessGroupPolicy') + results[guid]['MachinePolicy'] = \ + not int(parser.get(guid, 'NoMachinePolicy')) + results[guid]['UserPolicy'] = not int(parser.get(guid, 'NoUserPolicy')) + return results + + +def unregister_gp_extension(guid, smb_conf=None): + if not check_guid(guid): + return False + + lp, parser = parse_gpext_conf(smb_conf) + if guid in parser.sections(): + parser.remove_section(guid) + + atomic_write_conf(lp, parser) + + return True + + +def set_privileges(username, uid, gid): + ''' + Set current process privileges + ''' + + os.setegid(gid) + os.seteuid(uid) + + +def drop_privileges(username, func, *args): + ''' + Run supplied function with privileges for specified username. + ''' + current_uid = os.getuid() + + if not current_uid == 0: + raise Exception('Not enough permissions to drop privileges') + + user_uid = pwd.getpwnam(username).pw_uid + user_gid = pwd.getpwnam(username).pw_gid + + # Drop privileges + set_privileges(username, user_uid, user_gid) + + # We need to catch exception in order to be able to restore + # privileges later in this function + out = None + exc = None + try: + out = func(*args) + except Exception as e: + exc = e + + # Restore privileges + set_privileges('root', current_uid, 0) + + if exc: + raise exc + + return out diff --git a/internal/policies/certificate/python/vendor_samba/gp/util/logging.py b/internal/policies/certificate/python/vendor_samba/gp/util/logging.py new file mode 100644 index 000000000..a74a8707d --- /dev/null +++ b/internal/policies/certificate/python/vendor_samba/gp/util/logging.py @@ -0,0 +1,112 @@ +# +# samba-gpupdate enhanced logging +# +# Copyright (C) 2019-2020 BaseALT Ltd. +# Copyright (C) David Mulder 2022 +# +# 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 . + +import json +import datetime +import logging +import gettext +import random +import sys + +logger = logging.getLogger() +def logger_init(name, log_level): + logger = logging.getLogger(name) + logger.addHandler(logging.StreamHandler(sys.stdout)) + logger.setLevel(logging.CRITICAL) + if log_level == 1: + logger.setLevel(logging.ERROR) + elif log_level == 2: + logger.setLevel(logging.WARNING) + elif log_level == 3: + logger.setLevel(logging.INFO) + elif log_level >= 4: + logger.setLevel(logging.DEBUG) + +class slogm(object): + ''' + Structured log message class + ''' + def __init__(self, message, kwargs=None): + if kwargs is None: + kwargs = {} + self.message = message + self.kwargs = kwargs + if not isinstance(self.kwargs, dict): + self.kwargs = { 'val': self.kwargs } + + def __str__(self): + now = str(datetime.datetime.now().isoformat(sep=' ', timespec='milliseconds')) + args = dict() + args.update(self.kwargs) + result = '{}|{} | {}'.format(now, self.message, args) + + return result + +def message_with_code(mtype, message): + random.seed(message) + code = random.randint(0, 99999) + return '[' + mtype + str(code).rjust(5, '0') + ']| ' + \ + gettext.gettext(message) + +class log(object): + @staticmethod + def info(message, data=None): + if data is None: + data = {} + msg = message_with_code('I', message) + logger.info(slogm(msg, data)) + return msg + + @staticmethod + def warning(message, data=None): + if data is None: + data = {} + msg = message_with_code('W', message) + logger.warning(slogm(msg, data)) + return msg + + @staticmethod + def warn(message, data=None): + if data is None: + data = {} + return log.warning(message, data) + + @staticmethod + def error(message, data=None): + if data is None: + data = {} + msg = message_with_code('E', message) + logger.error(slogm(msg, data)) + return msg + + @staticmethod + def fatal(message, data=None): + if data is None: + data = {} + msg = message_with_code('F', message) + logger.fatal(slogm(msg, data)) + return msg + + @staticmethod + def debug(message, data=None): + if data is None: + data = {} + msg = message_with_code('D', message) + logger.debug(slogm(msg, data)) + return msg diff --git a/internal/policies/certificate/testdata/TestCertAutoenrollScript/golden/enroll_with_empty_advanced_configuration b/internal/policies/certificate/testdata/TestCertAutoenrollScript/golden/enroll_with_empty_advanced_configuration new file mode 100644 index 000000000..a031f00f8 --- /dev/null +++ b/internal/policies/certificate/testdata/TestCertAutoenrollScript/golden/enroll_with_empty_advanced_configuration @@ -0,0 +1,11 @@ +Loading smb.conf +[global] +realm = example.com + +Loading state file: #TMPDIR#/state/cert_gpo_state_keypress.tdb +Enroll called + +guid: adsys-cert-autoenroll-keypress +trust_dir: #TMPDIR#/trust; exists: True +private_dir: #TMPDIR#/private; exists: True +global_trust_dir: #TMPDIR#/ca-certificates; exists: False diff --git a/internal/policies/certificate/testdata/TestCertAutoenrollScript/golden/enroll_with_simple_configuration b/internal/policies/certificate/testdata/TestCertAutoenrollScript/golden/enroll_with_simple_configuration new file mode 100644 index 000000000..a031f00f8 --- /dev/null +++ b/internal/policies/certificate/testdata/TestCertAutoenrollScript/golden/enroll_with_simple_configuration @@ -0,0 +1,11 @@ +Loading smb.conf +[global] +realm = example.com + +Loading state file: #TMPDIR#/state/cert_gpo_state_keypress.tdb +Enroll called + +guid: adsys-cert-autoenroll-keypress +trust_dir: #TMPDIR#/trust; exists: True +private_dir: #TMPDIR#/private; exists: True +global_trust_dir: #TMPDIR#/ca-certificates; exists: False diff --git a/internal/policies/certificate/testdata/TestCertAutoenrollScript/golden/enroll_with_simple_configuration_and_debug_enabled b/internal/policies/certificate/testdata/TestCertAutoenrollScript/golden/enroll_with_simple_configuration_and_debug_enabled new file mode 100644 index 000000000..5e283063c --- /dev/null +++ b/internal/policies/certificate/testdata/TestCertAutoenrollScript/golden/enroll_with_simple_configuration_and_debug_enabled @@ -0,0 +1,12 @@ +Loading smb.conf +[global] +realm = example.com +log level = 10 + +Loading state file: #TMPDIR#/state/cert_gpo_state_keypress.tdb +Enroll called + +guid: adsys-cert-autoenroll-keypress +trust_dir: #TMPDIR#/trust; exists: True +private_dir: #TMPDIR#/private; exists: True +global_trust_dir: #TMPDIR#/ca-certificates; exists: False diff --git a/internal/policies/certificate/testdata/TestCertAutoenrollScript/golden/enroll_with_valid_advanced_configuration b/internal/policies/certificate/testdata/TestCertAutoenrollScript/golden/enroll_with_valid_advanced_configuration new file mode 100644 index 000000000..9fe4ef1bf --- /dev/null +++ b/internal/policies/certificate/testdata/TestCertAutoenrollScript/golden/enroll_with_valid_advanced_configuration @@ -0,0 +1,48 @@ +Loading smb.conf +[global] +realm = example.com + +Loading state file: #TMPDIR#/state/cert_gpo_state_keypress.tdb +Enroll called + +guid: adsys-cert-autoenroll-keypress +trust_dir: #TMPDIR#/trust; exists: True +private_dir: #TMPDIR#/private; exists: True +global_trust_dir: #TMPDIR#/ca-certificates; exists: False + +entries: +keyname: Software\Policies\Microsoft\Cryptography\PolicyServers\37c9dc30f207f27f61a2f7c3aed598a6e2920b54 +valuename: AuthFlags +type: 4 +data: 2 + +keyname: Software\Policies\Microsoft\Cryptography\PolicyServers\37c9dc30f207f27f61a2f7c3aed598a6e2920b54 +valuename: Cost +type: 4 +data: 2147483645 + +keyname: Software\Policies\Microsoft\Cryptography\PolicyServers\37c9dc30f207f27f61a2f7c3aed598a6e2920b54 +valuename: Flags +type: 4 +data: 20 + +keyname: Software\Policies\Microsoft\Cryptography\PolicyServers\37c9dc30f207f27f61a2f7c3aed598a6e2920b54 +valuename: FriendlyName +type: 1 +data: ActiveDirectoryEnrollmentPolicy + +keyname: Software\Policies\Microsoft\Cryptography\PolicyServers\37c9dc30f207f27f61a2f7c3aed598a6e2920b54 +valuename: PolicyID +type: 1 +data: {A5E9BF57-71C6-443A-B7FC-79EFA6F73EBD} + +keyname: Software\Policies\Microsoft\Cryptography\PolicyServers\37c9dc30f207f27f61a2f7c3aed598a6e2920b54 +valuename: URL +type: 1 +data: LDAP: + +keyname: Software\Policies\Microsoft\Cryptography\PolicyServers +valuename: Flags +type: 4 +data: 0 + diff --git a/internal/policies/certificate/testdata/TestCertAutoenrollScript/golden/unenroll b/internal/policies/certificate/testdata/TestCertAutoenrollScript/golden/unenroll new file mode 100644 index 000000000..8b44d2e33 --- /dev/null +++ b/internal/policies/certificate/testdata/TestCertAutoenrollScript/golden/unenroll @@ -0,0 +1,8 @@ +Loading smb.conf +[global] +realm = example.com + +Loading state file: #TMPDIR#/state/cert_gpo_state_keypress.tdb +Unenroll called +guid: adsys-cert-autoenroll-keypress +remove: ['ZXhhbXBsZS1DQQ=='] diff --git a/internal/policies/certificate/testdata/TestPolicyApply/golden/computer,_configured_to_enroll b/internal/policies/certificate/testdata/TestPolicyApply/golden/computer,_configured_to_enroll new file mode 100644 index 000000000..b53aaaf88 --- /dev/null +++ b/internal/policies/certificate/testdata/TestPolicyApply/golden/computer,_configured_to_enroll @@ -0,0 +1,3 @@ +enroll keypress example.com --policy_servers_json null --state_dir #TMPDIR#/statedir +KRB5CCNAME=#TMPDIR#/rundir/krb5cc/keypress +PYTHONPATH=#TMPDIR#/sharedir/python diff --git a/internal/policies/certificate/testdata/TestPolicyApply/golden/computer,_configured_to_enroll,_advanced_configuration b/internal/policies/certificate/testdata/TestPolicyApply/golden/computer,_configured_to_enroll,_advanced_configuration new file mode 100644 index 000000000..ebedab266 --- /dev/null +++ b/internal/policies/certificate/testdata/TestPolicyApply/golden/computer,_configured_to_enroll,_advanced_configuration @@ -0,0 +1,3 @@ +enroll keypress example.com --policy_servers_json [{"keyname":"Software\\Policies\\Microsoft\\Cryptography\\PolicyServers\\37c9dc30f207f27f61a2f7c3aed598a6e2920b54","valuename":"AuthFlags","data":2,"type":4},{"keyname":"Software\\Policies\\Microsoft\\Cryptography\\PolicyServers\\37c9dc30f207f27f61a2f7c3aed598a6e2920b54","valuename":"Cost","data":2147483645,"type":4},{"keyname":"Software\\Policies\\Microsoft\\Cryptography\\PolicyServers\\37c9dc30f207f27f61a2f7c3aed598a6e2920b54","valuename":"Flags","data":20,"type":4},{"keyname":"Software\\Policies\\Microsoft\\Cryptography\\PolicyServers\\37c9dc30f207f27f61a2f7c3aed598a6e2920b54","valuename":"FriendlyName","data":"ActiveDirectoryEnrollmentPolicy","type":1},{"keyname":"Software\\Policies\\Microsoft\\Cryptography\\PolicyServers\\37c9dc30f207f27f61a2f7c3aed598a6e2920b54","valuename":"PolicyID","data":"{A5E9BF57-71C6-443A-B7FC-79EFA6F73EBD}","type":1},{"keyname":"Software\\Policies\\Microsoft\\Cryptography\\PolicyServers\\37c9dc30f207f27f61a2f7c3aed598a6e2920b54","valuename":"URL","data":"LDAP:","type":1},{"keyname":"Software\\Policies\\Microsoft\\Cryptography\\PolicyServers","valuename":"Flags","data":0,"type":4}] --state_dir #TMPDIR#/statedir +KRB5CCNAME=#TMPDIR#/rundir/krb5cc/keypress +PYTHONPATH=#TMPDIR#/sharedir/python diff --git a/internal/policies/certificate/testdata/TestPolicyApply/golden/computer,_configured_to_unenroll b/internal/policies/certificate/testdata/TestPolicyApply/golden/computer,_configured_to_unenroll new file mode 100644 index 000000000..87bdd0251 --- /dev/null +++ b/internal/policies/certificate/testdata/TestPolicyApply/golden/computer,_configured_to_unenroll @@ -0,0 +1,3 @@ +unenroll keypress example.com --policy_servers_json null --state_dir #TMPDIR#/statedir +KRB5CCNAME=#TMPDIR#/rundir/krb5cc/keypress +PYTHONPATH=#TMPDIR#/sharedir/python diff --git a/internal/testutils/admock/samba/credentials/__init__.py b/internal/testutils/admock/samba/credentials/__init__.py index 6266c7dcc..2029abf3f 100644 --- a/internal/testutils/admock/samba/credentials/__init__.py +++ b/internal/testutils/admock/samba/credentials/__init__.py @@ -9,3 +9,6 @@ def set_kerberos_state(self, val): def guess(self, val): pass + + def get_username(self): + pass diff --git a/internal/testutils/admock/samba/dcerpc/preg.py b/internal/testutils/admock/samba/dcerpc/preg.py new file mode 100644 index 000000000..6c895c813 --- /dev/null +++ b/internal/testutils/admock/samba/dcerpc/preg.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass +from typing import Any + +@dataclass +class entry: + keyname: str = None + valuename: str = None + type: int = None + data: Any = None diff --git a/internal/testutils/admock/samba/param/__init__.py b/internal/testutils/admock/samba/param/__init__.py index 574eddfc1..f85b80128 100644 --- a/internal/testutils/admock/samba/param/__init__.py +++ b/internal/testutils/admock/samba/param/__init__.py @@ -1,2 +1,6 @@ -def LoadParm(): - return \ No newline at end of file +def LoadParm(smb_conf=None): + if smb_conf is None: + return + print('Loading smb.conf') + with open(smb_conf, 'r') as f: + print(f.read()) diff --git a/internal/testutils/admock/vendor_samba/__init__.py b/internal/testutils/admock/vendor_samba/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/internal/testutils/admock/vendor_samba/gp/__init__.py b/internal/testutils/admock/vendor_samba/gp/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/internal/testutils/admock/vendor_samba/gp/gp_cert_auto_enroll_ext.py b/internal/testutils/admock/vendor_samba/gp/gp_cert_auto_enroll_ext.py new file mode 100644 index 000000000..31f2dbcaa --- /dev/null +++ b/internal/testutils/admock/vendor_samba/gp/gp_cert_auto_enroll_ext.py @@ -0,0 +1,38 @@ +import os + +class gp_cert_auto_enroll_ext(object): + def __init__(self, _lp, _credentials, _username, _store): + pass + + def cache_get_all_attribute_values(self, _guid): + return {'ZXhhbXBsZS1DQQ==': '{"files": ["/var/lib/adsys/certs/galacticcafe-CA.0.crt"]}'} + + def __enroll(self, guid, entries, trust_dir, private_dir, global_trust_dir): + if os.getenv('ADSYS_WANT_AUTOENROLL_ERROR'): + raise Exception('Autoenroll error requested') + + print('Enroll called') + print() + print(f'guid: {guid}') + print(f'trust_dir: {trust_dir}; exists: {os.path.exists(trust_dir)}') + print(f'private_dir: {private_dir}; exists: {os.path.exists(private_dir)}') + print(f'global_trust_dir: {global_trust_dir}; exists: {os.path.exists(global_trust_dir)}') + + if entries == []: + return + + print('\nentries:') + for entry in entries: + print(f'''keyname: {entry.keyname} +valuename: {entry.valuename} +type: {entry.type} +data: {entry.data} +''') + + def clean(self, guid, remove=None): + if os.getenv('ADSYS_WANT_AUTOENROLL_ERROR'): + raise Exception('Autoenroll error requested') + + print('Unenroll called') + print(f'guid: {guid}') + print(f'remove: {remove}') diff --git a/internal/testutils/admock/vendor_samba/gp/gpclass.py b/internal/testutils/admock/vendor_samba/gp/gpclass.py new file mode 100644 index 000000000..0a1735aec --- /dev/null +++ b/internal/testutils/admock/vendor_samba/gp/gpclass.py @@ -0,0 +1,4 @@ +import os + +def GPOStorage(state_file): + print(f'Loading state file: {state_file}')