cfengine3 mechanism for signed updates

Valentin <[email protected]>
Newsgroups gmane.comp.sysutils.cfengine.general
Message-ID <[email protected]>
Hi,

As laid out in a previous mail [1] I wanted to have a way for 
cfengine-clients to independently check signatures on the distributed files.
This way the clients should never use/execute updated files from the hub 
if they were not independently signed.
I now have a first working Version of this working (thanks to Nick who 
answered my many questions on IRC and the mailing list).
I tested it only on Debian Linux but tried to keep it general purpose 
and avoid using external dependencies that are unavailable on other 
platforms.
External dependencies are hashdeep and gpgv, both easily installable on 
debian via apt.
Specifically i am using debian 10.10 and cfengine 3.12.1-2 from the 
debian repository.
The files i wrote do not change the MPF but extend and use it.
Although for cfengine 3.12.1 in debian 1 known bug [2] and another 
change [3] must be patched. otherwise the augments i set up in def.json 
don't work.

I placed update_signed.cf in the additional folder lib/custom and set 
the masterfiles and staging path according to the debian default to 
reside in /var/lib/cfengine3.

The complete workflow is also outlined in the README.md but in short the 
relevant changes for using it are:
after changes to the masterfiles:
- sign the masterfiles and dstribute the hashes and hashes.sig with them 
for checking
before bootstrapping a client:
- distribute the trusted keys database to the client in the location set 
in def.json
- this should not be done by cfenginge as you will otherwise distribute 
your trust via the same mechanism you're trying to secure

I'd love to hear your opinion on this and I'd be happy to improve this 
further.

Cheers,
Valentin

[1] https://groups.google.com/g/help-cfengine/c/pcz93QPjr7w/m/PaZQIVHwAQAJ
[2] https://tracker.mender.io/browse/CFE-2953
[3] 
https://github.com/cfengine/masterfiles/commit/26eb50bd827e88041fac2c90b026f781e79d9055

-- 
You received this message because you are subscribed to the Google Groups "help-cfengine" group.
To unsubscribe from this group and stop receiving emails from it, send an email to [email protected].
To view this discussion on the web visit https://groups.google.com/d/msgid/help-cfengine/dda6d592-0769-e0ee-0870-a8d22a1b443d%40gmail.com.
create_privkey.sh (application/x-shellscript, 744 B)
#!/bin/bash

PGPDIR="${HOME}/.cfengine-gpg"

if [ -d ${PGPDIR} ]; then
    echo "Error: key location exists (${PGPDIR}), exiting"
    exit 1
fi

mkdir ${PGPDIR}
chmod 700 ${PGPDIR}

gpg --homedir ${PGPDIR} --default-new-key-algo "rsa4096/cert,sign+rsa4096/encr" \
                        --gen-key

KEYID="$(gpg --homedir "${PGPDIR}" --list-keys --with-colons | \
         awk -F: '/fpr:/ {print $10}')"
# set expiry to never
echo -e "0\ny\n" | gpg --homedir ~/.cfengine-gpg \
                       --command-fd 0 \
                       --expert \
                       --edit-key ${KEYID} \
                       expire

KEYFILE="cfengine-trust.d/${USER}@$(hostname --fqdn)"
gpg --homedir ${PGPDIR} --armor --export ${KEYID} > ${KEYFILE}
README.md (text/markdown, 2.8 KB)
signed updates
===========

## prerequisites

all hosts managed by cfengine must habve hashdeep and gpgv installed in order to process updates.

on one (or multiple) trusted machine(s) used to sign changes to the masterfiles you will need to setup a gpg environment (see below). this requres hashdeep and gpg to be installed.

## updates to masterfiles

after doing all the changes to your masterfiles you need to save the hashdeep hashes over all the files to masterfiles/hashes and afterwards add a detached gpg signature to that file.
for the default setup the script sign_repo.sh script does this automaticaly.

## setting up the gpg environment

To not interfere with existing gpg setups create a seperate gpg homedir (.cfengine-gpg in this case).
The signing script expects that you have set up ./.cfengine-gpg either in the same directory as the script or top level in your homedir as your gpg homedir for the cfengine signatures.
The following steps have been scripted into create_privkey.sh and create_trustfile.sh. In any way you should prepare a very long passphrase to keep your key protected.

### manually setting up the gpg environment

the manual way of creating the setup:

    mkdir ~/.cfengine-gpg; chmod 700 ~/.cfengine-gpg

To create a new key for signing the run:

    gpg --homedir ~/.cfengine-gpg --default-new-key-algo "rsa4096/cert,sign+rsa4096/encr" \
                                  --gen-key

After that set your key to not expire:

    echo -e "0\ny\n" | gpg --homedir ~/.cfengine-gpg --command-fd 0 \
    --expert --edit-key <key-id> expire

or do the changes manually:

    gpg --homedir ~/.cfengine-gpg <key-id>
    > expire
    > 0         # for never
    > y         # to confirm
    > key 1     # for the first subkey (mostly there will be only one)
    > expire
    > 0
    > y
    > save

To add the key to the trusted keys database:

    # list keys
    gpg --homedir ~/.cfengine-gpg --list-keys
    # export the one you want to use by id ascii-armored
    gpg --homedir ~/.cfengine-gpg --armor --export <key-id> > <keyfile>

To add that key to a keyring that can be used to check signatures:

    # first create the file
    gpg --no-default-keyring --keyring ./cfengine-trust.gpg --fingerprint
    # import the previously exported key
    gpg --no-default-keyring --keyring ./cfengine-trust.gpg --import <keyfile>
    # trust the key, select 5 for ultimate trust
    gpg --no-default-keyring --keyring ./cfengine-trust.gpg --edit-key <key-id> trust

### create a hash file manually

    cd masterfiles
    hashdeep -r -l -j0 ./ > hashes

afterwards you need to manually edit this file to:
* remove leading ./ in the paths
* remove the lines for hashes, hashes.sig and cf_promises_validated

### sign a hash file manually

    gpg --homedir .cfengine-gpg -sb masterfiles/hashes
sign_repo.sh (application/x-shellscript, 1.1 KB)
#!/bin/bash

# default
MASTER_PATH="./masterfiles"

function Usage()
{
    echo "Usage: $0 [MASTER_FILE_PATH]"
    exit 1
}

if [ $# -gt 1 ]; then
    Usage
elif [ $# -eq 1 ]; then
    MASTER_PATH="$1"
fi

if [ ! -d "$MASTER_PATH" ]; then
    echo "ERROR: directory $MASTER_PATH does not exist"
    Usage
fi

cd ${MASTER_PATH}

if [ -d ../.cfengine-gpg ]; then
    GPG_DIR=../.cfengine-gpg
elif [ -d .cfengine-gpg ]; then
    GPG_DIR=.cfengine-gpg
elif [ -d ~/.cfengine-gpg ]; then
    GPG_DIR=~/.cfengine-gpg
else
    echo "Error: .cfengine-gpg was not found; exiting"
    exit 1
fi

HASHFILE=$(tempfile -p hashes)

echo "preparing hash list in ${HASHFILE}"
find -maxdepth 1 ! -name cf_promises_validated ! -name hashes ! -name hashes.sig ! -name "." | \
    sed 's|^\./||g' | \
    xargs hashdeep -r -l -j0 \
> ${HASHFILE}

if cmp -s "${HASHFILE}" hashes ; then
    echo "no changes detected, exiting"
    rm -f "${HASHFILE}"
    exit 0
else
    echo "updating hashes"
    cat "${HASHFILE}" > hashes
    rm -f "${HASHFILE}"
fi

gpg --homedir "${GPG_DIR}" --sign --detach-sign hashes
create_trustfile.sh (application/x-shellscript, 973 B)
#!/bin/bash

TRUSTFILE="./cfengine-trust.gpg"

if [ -e "${TRUSTFILE}" ]; then
    echo "${TRUSTFILE} exists; creating backup"
    [ -e "${TRUSTFILE}-old" ] && rm -f "${TRUSTFILE}-old"
    mv "${TRUSTFILE}" "${TRUSTFILE}-old"
fi

[ -e "${TRUSTFILE}~" ] && rm -f "${TRUSTFILE}~"
echo "creating empty keyring ${TRUSTFILE}"
gpg --no-default-keyring --keyring "${TRUSTFILE}" --fingerprint

touch "${TRUSTFILE}"
for KEY in cfengine-trust.d/*; do
    echo "importing ${KEY}"
    gpg --no-default-keyring --keyring "${TRUSTFILE}" --import ${KEY}
done

# taken from https://raymii.org/s/articles/GPG_noninteractive_batch_sign_trust_and_send_gnupg_keys.html
for fpr in $(gpg --no-default-keyring --keyring "${TRUSTFILE}" \
                 --list-keys --with-colons | \
             awk -F: '/fpr:/ {print $10}' | \
             sort -u); do
    echo -e "5\ny\n" | \
    gpg --no-default-keyring --keyring "${TRUSTFILE}" \
        --command-fd 0 --expert --edit-key $fpr trust
done
def.json (application/json, 390 B) - not displayed
update_signed.cf (text/plain, 15 KB)
#########################################################
#
# signed_update_policy
# - Cfengine policy update (masterfiles -> staging -> inputs)
#
#########################################################

bundle agent cfe_signed_update_policy
# @brief Update inputs from masterfiles via staging directory and a signature check
#
# @description This bundle updates staging area. It uses the
#              cf_promises_validated file as a gating mechanism to prevent
#              unnecessary burden on the server from remote agents checking to
#              see if each individual file needs an update.
# - The class `validated_updates_ready` is defined when `cf_promsies_validated` is repaired
# - Executing `cf-agent -KIf update.cf --define validated_updates_ready` will
#   cause the update policy to scan all files in masterfiles, modules, and
#   plugins to be scanned for update.
{
  vars:
      "staging_dir"
        string => "$(def.signed_update_policy_staging_dir)",
        comment => "Directory for staging files before signature checks",
        handle => "cfe_signed_update_policy_vars_staging_dir";

      "master_location"
        string => "$(update_def.mpf_update_policy_master_location)",
        comment => "The path to request updates from the policy server.",
        handle => "cfe_signed_update_policy_vars_master_location";
      
      "inputs_dir"
        string => translatepath("$(sys.inputdir)"),
        comment => "Directory containing CFEngine policies",
        handle => "cfe_signed_update_policy_vars_inputs_dir";

      "trusted_signatures"
        string => "$(def.signed_update_policy_trusted_signatures)",
        comment => "ifile containing the gpg keys trusted by cfengine updates.",
        handle => "cfe_signed_update_policy_vars_trusted_signatures";

    windows::

      "modules_dir"        string => "/var/cfengine/modules",      # NB! NOT $(sys.workdir) on Windows !
      comment => "Directory containing CFEngine modules",
      handle => "cfe_signed_update_policy_vars_modules_dir_windows";

      "plugins_dir"        string => "/var/cfengine/plugins",      # NB! NOT $(sys.workdir) on Windows !
      comment => "Directory containing CFEngine plugins",
      handle => "cfe_signed_update_policy_vars_plugins_dir_windows";

    !windows::

      "modules_dir"        string => translatepath("$(sys.workdir)/modules"),
      comment => "Directory containing CFEngine modules",
      handle => "cfe_signed_update_policy_vars_modules_dir";

      "plugins_dir"        string => translatepath("$(sys.workdir)/plugins"),
      comment => "Directory containing CFEngine plugins",
      handle => "cfe_signed_update_policy_vars_plugins_dir";

    any::

      "file_check"         string => translatepath("$(inputs_dir)/promises.cf"),
      comment => "Path to a policy file",
      handle => "cfe_signed_update_vars_file_check";

      "ppkeys_file"        string => translatepath("$(sys.workdir)/ppkeys/localhost.pub"),
      comment => "Path to public key file",
      handle => "cfe_signed_update_policy_vars_ppkeys_file";

      "postgresdb_dir"        string => "$(sys.workdir)/state/pg/data",
      comment => "Directory where Postgres database files will be stored on hub -",
      handle => "cfe_signed_update_policy_postgresdb_dir";

      "postgresdb_log"        string => "/var/log/postgresql.log",
      comment => "File where Postgres database files will be logging -",
      handle => "cfe_signed_update_policy_postgresdb_log_file";

    linux::

      "hash_command"        string => "/usr/bin/hashdeep",
        comment => "hashdeep command location on linux systems";

      "signature_command" string => "/usr/bin/gpgv",
        comment => "gpgv command location on linux systems";

    !linux::

      "hash_command"        string => "hashdeep",
        comment => "hashdeep command, location on non-linux systems is unknown to us";

    windows::

      "signature_command" string => "gpgvi.exe",
        comment => "gpgv command on windows";

    # create the files list after the copy operation if something in staging changed or cf_promises_validated was copied
    update_staging_repaired|validated_updates_ready::

      "staging_hash_check_files"
        slist => lsdir("$(staging_dir)", "^(?!(\.$|\.\.$|cf_promises_validated$|hashes$|hashes.sig$)).*", "false"),
        depends_on => { "cfe_signed_update_policy_files_staging_dir" },
        comment => "all directory content that shall be hash-checked",
        handle => "var_staging_hash_check_files";

      "staging_hash_check_files_str"
        string => join(", ", "staging_hash_check_files");

  classes:

      "validated_updates_ready"
        expression => "cfengine_internal_disable_cf_promises_validated",
        comment => "If cf_promises_validated is disabled, then updates are
                    always considered validated.";

    any::

      "local_files_ok" expression => fileexists("$(file_check)"),
      comment => "Check for $(sys.masterdir)/promises.cf",
      handle => "cfe_signed_update_classes_files_ok";

      # create a global files_ok class
      "cfe_signed_trigger" expression => "local_files_ok",
      classes => u_if_else("files_ok", "files_ok");

  files:

    any::  # its only copied to staging, no issue for hub or anyone, original was !am_policy_hub::

      "$(staging_dir)/cf_promises_validated"
      comment => "Check whether a validation stamp is available for a new policy update to reduce the distributed load",
      handle => "cfe_signed_update_policy_check_valid_update",
      copy_from => u_rcp("$(master_location)/cf_promises_validated", @(update_def.policy_servers)),
      action => u_immediate,
      classes => u_if_repaired("validated_updates_ready");

    ## warn if modules or plugins fail, manually update them
    !am_policy_hub.!windows::

      "$(modules_dir)"
      comment => "Always warn for differing modules files on client side",
      handle => "cfe_signed_update_policy_files_update_modules",
      copy_from => u_rcp("$(modules_dir)", @(update_def.policy_servers)),
      depth_search => u_recurse("inf"),
      perms => u_m("755"),
      action => u_warn_immediate,
      classes => u_results("bundle", "update_modules");

      "$(plugins_dir)"
      comment => "Always warn for differing plugins files on client side",
      handle => "cfe_signed_update_policy_files_update_plugins",
      copy_from => u_rcp("$(plugins_dir)", @(update_def.policy_servers)),
      depth_search => u_recurse("inf"),
      perms => u_m("755"),
      action => u_warn_immediate,
      classes => u_results("bundle", "update_plugins");

    !am_policy_hub.windows::

      "$(sys.workdir)\modules"
      comment => "Always warn for differing modules files on client side (Windows)",
      handle => "cfe_signed_update_policy_files_staging_update_modules_windows",
      copy_from => u_rcp("$(modules_dir)", @(update_def.policy_servers)),
      depth_search => u_recurse("inf"),
      perms => u_m("755"),
      action => u_warn_immediate,
      classes => u_results("bundle", "update_modules_windows");

      "$(sys.workdir)\plugins"
      comment => "Always warn for differing plugins files on client side (Windows)",
      handle => "cfe_signed_update_policy_files_staging_update_plugins_windows",
      copy_from => u_rcp("$(plugins_dir)", @(update_def.policy_servers)),
      depth_search => u_recurse("inf"),
      perms => u_m("755"),
      action => u_warn_immediate,
      classes => u_results("bundle", "update_plugins_windows");

    ##update staging dir
    am_policy_hub|validated_updates_ready::  # policy hub should always put masterfiles in inputs in order to check new policy

      "$(staging_dir)"
      comment => "Copy policy updates from master source on policy server if a new validation was acquired",
      handle => "cfe_signed_update_policy_files_staging_dir",
      copy_from => u_rcp_purging("$(master_location)", @(update_def.policy_servers)), #need to purge and not keep backups for hash checks later
      depth_search => u_recurse("inf"),
      file_select  => u_input_files_signed,
      action => u_immediate,
      classes => u_results("bundle", "update_staging");

    update_staging_not_kept::

      "$(staging_dir)/cf_promises_validated" -> { "CFE-2587" }
        delete => u_tidy,
        comment => "If there is any problem copying to $(staging_dir) then purge
                    the cf_promises_validated file must be purged so that
                    subsequent agent runs will perform a full scan.";

    ##copy staging to input when signatures and hashes were verified
    staging_signature_kept&staging_hash_check_kept::

      "$(inputs_dir)"
      comment => "Copy policy updates from master source on policy server if a new validation was acquired",
      handle => "cfe_signed_update_policy_files_inputs_dir",
      copy_from => u_cp("$(staging_dir)"),
      depth_search => u_recurse("inf"),
      file_select  => u_input_files_signed,
      action => u_immediate,
      classes => u_results("bundle", "update_inputs");

    update_inputs_not_kept|staging_hash_check_failed|staging_signature_failed::

      "$(staging_dir)/cf_promises_validated" -> { "CFE-2587" }
        delete => u_tidy,
        comment => "If the signature verification failed or there is any problem
                    copying to $(inputs_dir) then purge the cf_promises_validated
                    in staging must be purged so that subsequent agent runs will
                    perform a full scan.";

    am_policy_hub::

      "$(master_location)/."
      comment => "Make sure masterfiles folder has right file permissions",
      handle => "cfe_signed_update_policy_files_sys_workdir_masterfiles",
      perms => u_m($(update_def.masterfiles_perms_mode)),
      depth_search => u_recurse_basedir("inf"),
      action => u_immediate;


    !policy_server.enable_cfengine_enterprise_hub_ha::

      "$(sys.workdir)/policy_server.dat"
      comment => "Copy policy_server.dat file from server",
      handle => "cfe_signed_update_ha_policy_server",
      copy_from => u_rcp("$(sys.workdir)/state/master_hub.dat", @(update_def.policy_servers)),
      action => u_immediate,
      classes => u_if_repaired("replica_failover");  # not needed ?


  commands:
    #check signatures and hashes if something in staging including cf_promises_validated changed
    update_staging_repaired|validated_updates_ready::

      "$(signature_command)"
        arglist => { "--keyring $(trusted_signatures)", "hashes.sig", "hashes" },
        comment => "check signature on hashes",
        depends_on => { "cfe_signed_update_policy_files_staging_dir" },
        handle => "staging_hash_check",
        contain => signatures_contain("$(staging_dir)"),
        classes => signatures_return;

    staging_signature_kept::

      "$(hash_command)"
        arglist => { "-r", "-l", "-k hashes", "-a", @(staging_hash_check_files) },
        comment => "check hash values",
        depends_on => { "var_staging_hash_check_files" },
        handle => "staging_hash_check",
        contain => hashdeep_contain("$(staging_dir)"),
        classes => hash_checks_return;

    staging_hash_check_failed::

      "$(hash_command)"
        arglist => { "-r", "-l", @(staging_hash_check_files) },
        comment => "print updated hash values",
        depends_on => { "var_staging_hash_check_files" },
        handle => "staging_hash_update",
        contain => hashdeep_contain("$(staging_dir)");

  reports:
    (!update_staging_repaired)&update_inputs_repaired::
      "WARNING: someone messed with the inputs directory!";
      "No staging wasn't repaired but it differed from inputs!";

    staging_hash_check_failed::
      "hash checks in $(staging_dir) have failed.";
      "if the changes were on purpose update the hashes the masterfiles and sign the file.";
      "the current hashes were printed to the console.";

    staging_signature_failed::
      "the signature on the hashes file was invalid!";

    update_modules_failed|update_modules_windows_failed::
      "modules dffier from cfengine hub. please update them manually.";
      "this is neccesary because we don't check their signatures.";

    update_plugins_failed|update_plugins_windows_failed::
      "plugins dffier from cfengine hub. please update them manually.";
      "this is neccesary because we don't check their signatures.";

    DEBUG::
      "DEBUG: $(this.bundle)";
    DEBUG&validated_updates_ready::
      "DEBUG: validated_updates_ready";
    DEBUG&update_staging_kept::
      "DEBUG: update_staging_kept";
    DEBUG&update_staging_not_kept::
      "DEBUG: update_staging_not_kept";
    DEBUG&update_staging_repaired::
      "DEBUG: update_staging_repaired";
    DEBUG&staging_signature_kept::
      "DEBUG: files which hashes need checking:"
        depends_on => { "var_staging_hash_check_files" };
      "$(staging_hash_check_files_str)"
        depends_on => { "var_staging_hash_check_files" };
    DEBUG&staging_hash_check_kept::
      "DEBUG: staging_hash_check_kept";
    DEBUG&update_inputs_not_kept::
      "DEBUG: update_inputs_not_kept";
    DEBUG&update_inputs_kept::
      "DEBUG: update_inputs_kept";
    DEBUG&update_inputs_repaired::
      "DEBUG: update_inputs_repaired";
    DEBUG&staging_signature_kept::
      "DEBUG: staging_signature_kept";

}

#########################################################
# Self-contained bodies specific to signed updates
#########################################################

body file_select u_input_files_signed
{
      leaf_name => { "hashes", "hashes.sig", ".*\.md", @(update_def.input_name_patterns) };
      file_result => "leaf_name";
}
#########################################################

body copy_from u_rcp_purging(from,server)
{
      source      => "$(from)";
      compare     => "digest";
      trustkey    => "false";
      copy_backup => "false";
      purge       => "true";

    !am_policy_hub::
      servers => { "$(server)" };

    !am_policy_hub.sys_policy_hub_port_exists::
      portnumber => "$(sys.policy_hub_port)";

    cfengine_internal_encrypt_transfers::
      encrypt => "true";

    cfengine_internal_preserve_permissions::
      preserve => "true";

    cfengine_internal_verify_update_transfers::
      verify      => "true";
}

#########################################################

body action u_warn_immediate
{
      action_policy => "warn";
      ifelapsed => "0";
}

#########################################################

body classes signatures_return
{
  kept_returncodes => { "0" };
  repaired_returncodes => {};
  failed_returncodes => { "1" };
  promise_kept => { "staging_signature_kept" };
  repair_failed => { "staging_signature_failed" };
}

#########################################################

body contain signatures_contain(dir)
{
  chdir => "$(dir)";
}

#########################################################

body classes hash_checks_return
{
  kept_returncodes => { "0" };
  repaired_returncodes => {};
  failed_returncodes => { "1", "2", "64", "128" };
  promise_kept => { "staging_hash_check_kept" };
  repair_failed => { "staging_hash_check_failed" };
}

#########################################################

body contain hashdeep_contain(dir)
{
  chdir => "$(dir)";
  useshell => "useshell";
  exec_timeout => "30";
}
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.