mirror of
https://github.com/valitydev/salt.git
synced 2024-11-09 01:36:48 +00:00
856 lines
33 KiB
Python
856 lines
33 KiB
Python
# -*- coding: utf-8 -*-
|
|
'''
|
|
The crypt module manages all of the cryptography functions for minions and
|
|
masters, encrypting and decrypting payloads, preparing messages, and
|
|
authenticating peers
|
|
'''
|
|
from __future__ import absolute_import
|
|
from __future__ import print_function
|
|
|
|
# Import python libs
|
|
import os
|
|
import sys
|
|
import time
|
|
import hmac
|
|
import shutil
|
|
import hashlib
|
|
import logging
|
|
import traceback
|
|
import binascii
|
|
from salt.ext.six.moves import zip
|
|
|
|
# Import third party libs
|
|
try:
|
|
from M2Crypto import RSA, EVP
|
|
from Crypto.Cipher import AES
|
|
except ImportError:
|
|
# No need for crypt in local mode
|
|
pass
|
|
|
|
# Import salt libs
|
|
import salt.defaults.exitcodes
|
|
import salt.utils
|
|
import salt.payload
|
|
import salt.utils.verify
|
|
import salt.version
|
|
from salt.exceptions import (
|
|
AuthenticationError, SaltClientError, SaltReqTimeoutError
|
|
)
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
def dropfile(cachedir, user=None, sock_dir=None):
|
|
'''
|
|
Set an AES dropfile to update the publish session key
|
|
|
|
A dropfile is checked periodically by master workers to determine
|
|
if AES key rotation has occurred.
|
|
'''
|
|
dfnt = os.path.join(cachedir, '.dfnt')
|
|
dfn = os.path.join(cachedir, '.dfn')
|
|
|
|
def ready():
|
|
'''
|
|
Because MWorker._update_aes uses second-precision mtime
|
|
to detect changes to the file, we must avoid writing two
|
|
versions with the same mtime.
|
|
|
|
Note that this only makes rapid updates in serial safe: concurrent
|
|
updates could still both pass this check and then write two different
|
|
keys with the same mtime.
|
|
'''
|
|
try:
|
|
stats = os.stat(dfn)
|
|
except os.error:
|
|
# Not there, go ahead and write it
|
|
return True
|
|
else:
|
|
if stats.st_mtime == time.time():
|
|
# The mtime is the current time, we must
|
|
# wait until time has moved on.
|
|
return False
|
|
else:
|
|
return True
|
|
|
|
while not ready():
|
|
log.warning('Waiting before writing {0}'.format(dfn))
|
|
time.sleep(1)
|
|
|
|
log.info('Rotating AES key')
|
|
aes = Crypticle.generate_key_string()
|
|
mask = os.umask(191)
|
|
with salt.utils.fopen(dfnt, 'w+') as fp_:
|
|
fp_.write(aes)
|
|
if user:
|
|
try:
|
|
import pwd
|
|
uid = pwd.getpwnam(user).pw_uid
|
|
os.chown(dfnt, uid, -1)
|
|
except (KeyError, ImportError, OSError, IOError):
|
|
pass
|
|
|
|
shutil.move(dfnt, dfn)
|
|
os.umask(mask)
|
|
if sock_dir:
|
|
event = salt.utils.event.SaltEvent('master', sock_dir)
|
|
event.fire_event({'rotate_aes_key': True}, tag='key')
|
|
|
|
|
|
def gen_keys(keydir, keyname, keysize, user=None):
|
|
'''
|
|
Generate a RSA public keypair for use with salt
|
|
|
|
:param str keydir: The directory to write the keypair to
|
|
:param str keyname: The type of salt server for whom this key should be written. (i.e. 'master' or 'minion')
|
|
:param int keysize: The number of bits in the key
|
|
:param str user: The user on the system who should own this keypair
|
|
|
|
:rtype: str
|
|
:return: Path on the filesystem to the RSA private key
|
|
'''
|
|
base = os.path.join(keydir, keyname)
|
|
priv = '{0}.pem'.format(base)
|
|
pub = '{0}.pub'.format(base)
|
|
|
|
gen = RSA.gen_key(keysize, 65537, callback=lambda x, y, z: None)
|
|
if os.path.isfile(priv):
|
|
# Between first checking and the generation another process has made
|
|
# a key! Use the winner's key
|
|
return priv
|
|
cumask = os.umask(191)
|
|
gen.save_key(priv, None)
|
|
os.umask(cumask)
|
|
gen.save_pub_key(pub)
|
|
os.chmod(priv, 256)
|
|
if user:
|
|
try:
|
|
import pwd
|
|
uid = pwd.getpwnam(user).pw_uid
|
|
os.chown(priv, uid, -1)
|
|
os.chown(pub, uid, -1)
|
|
except (KeyError, ImportError, OSError):
|
|
# The specified user was not found, allow the backup systems to
|
|
# report the error
|
|
pass
|
|
return priv
|
|
|
|
|
|
def sign_message(privkey_path, message):
|
|
'''
|
|
Use M2Crypto's EVP ("Envelope") functions to sign a message. Returns the signature.
|
|
'''
|
|
log.debug('salt.crypt.sign_message: Loading private key')
|
|
evp_rsa = EVP.load_key(privkey_path)
|
|
evp_rsa.sign_init()
|
|
evp_rsa.sign_update(message)
|
|
log.debug('salt.crypt.sign_message: Signing message.')
|
|
return evp_rsa.sign_final()
|
|
|
|
|
|
def verify_signature(pubkey_path, message, signature):
|
|
'''
|
|
Use M2Crypto's EVP ("Envelope") functions to verify the signature on a message.
|
|
Returns True for valid signature.
|
|
'''
|
|
# Verify that the signature is valid
|
|
log.debug('salt.crypt.verify_signature: Loading public key')
|
|
pubkey = RSA.load_pub_key(pubkey_path)
|
|
verify_evp = EVP.PKey()
|
|
verify_evp.assign_rsa(pubkey)
|
|
verify_evp.verify_init()
|
|
verify_evp.verify_update(message)
|
|
log.debug('salt.crypt.verify_signature: Verifying signature')
|
|
result = verify_evp.verify_final(signature)
|
|
return result
|
|
|
|
|
|
def gen_signature(priv_path, pub_path, sign_path):
|
|
'''
|
|
creates a signature for the given public-key with
|
|
the given private key and writes it to sign_path
|
|
'''
|
|
|
|
with salt.utils.fopen(pub_path) as fp_:
|
|
mpub_64 = fp_.read()
|
|
|
|
mpub_sig = sign_message(priv_path, mpub_64)
|
|
mpub_sig_64 = binascii.b2a_base64(mpub_sig)
|
|
if os.path.isfile(sign_path):
|
|
return False
|
|
log.trace('Calculating signature for {0} with {1}'
|
|
.format(os.path.basename(pub_path),
|
|
os.path.basename(priv_path)))
|
|
|
|
if os.path.isfile(sign_path):
|
|
log.trace('Signature file {0} already exists, please '
|
|
'remove it first and try again'.format(sign_path))
|
|
else:
|
|
with salt.utils.fopen(sign_path, 'w+') as sig_f:
|
|
sig_f.write(mpub_sig_64)
|
|
log.trace('Wrote signature to {0}'.format(sign_path))
|
|
return True
|
|
|
|
|
|
class MasterKeys(dict):
|
|
'''
|
|
The Master Keys class is used to manage the RSA public key pair used for
|
|
authentication by the master.
|
|
|
|
It also generates a signing key-pair if enabled with master_sign_key_name.
|
|
'''
|
|
def __init__(self, opts):
|
|
super(MasterKeys, self).__init__()
|
|
self.opts = opts
|
|
self.pub_path = os.path.join(self.opts['pki_dir'], 'master.pub')
|
|
self.rsa_path = os.path.join(self.opts['pki_dir'], 'master.pem')
|
|
|
|
self.key = self.__get_keys()
|
|
self.token = self.__gen_token()
|
|
self.pub_signature = None
|
|
|
|
# set names for the signing key-pairs
|
|
if opts['master_sign_pubkey']:
|
|
|
|
# if only the signature is available, use that
|
|
if opts['master_use_pubkey_signature']:
|
|
self.sig_path = os.path.join(self.opts['pki_dir'],
|
|
opts['master_pubkey_signature'])
|
|
if os.path.isfile(self.sig_path):
|
|
self.pub_signature = salt.utils.fopen(self.sig_path).read()
|
|
log.info('Read {0}\'s signature from {1}'
|
|
''.format(os.path.basename(self.pub_path),
|
|
self.opts['master_pubkey_signature']))
|
|
else:
|
|
log.error('Signing the master.pub key with a signature is enabled '
|
|
'but no signature file found at the defined location '
|
|
'{0}'.format(self.sig_path))
|
|
log.error('The signature-file may be either named differently '
|
|
'or has to be created with \'salt-key --gen-signature\'')
|
|
sys.exit(1)
|
|
|
|
# create a new signing key-pair to sign the masters
|
|
# auth-replies when a minion tries to connect
|
|
else:
|
|
self.pub_sign_path = os.path.join(self.opts['pki_dir'],
|
|
opts['master_sign_key_name'] + '.pub')
|
|
self.rsa_sign_path = os.path.join(self.opts['pki_dir'],
|
|
opts['master_sign_key_name'] + '.pem')
|
|
self.sign_key = self.__get_keys(name=opts['master_sign_key_name'])
|
|
|
|
def __get_keys(self, name='master'):
|
|
'''
|
|
Returns a key object for a key in the pki-dir
|
|
'''
|
|
path = os.path.join(self.opts['pki_dir'],
|
|
name + '.pem')
|
|
if os.path.exists(path):
|
|
key = RSA.load_key(path)
|
|
log.debug('Loaded {0} key: {1}'.format(name, path))
|
|
else:
|
|
log.info('Generating {0} keys: {1}'.format(name, self.opts['pki_dir']))
|
|
gen_keys(self.opts['pki_dir'],
|
|
name,
|
|
self.opts['keysize'],
|
|
self.opts.get('user'))
|
|
key = RSA.load_key(self.rsa_path)
|
|
return key
|
|
|
|
def __gen_token(self):
|
|
'''
|
|
Generate the authentication token
|
|
'''
|
|
return self.key.private_encrypt('salty bacon', 5)
|
|
|
|
def get_pub_str(self, name='master'):
|
|
'''
|
|
Return the string representation of a public key
|
|
in the pki-directory
|
|
'''
|
|
path = os.path.join(self.opts['pki_dir'],
|
|
name + '.pub')
|
|
if not os.path.isfile(path):
|
|
key = self.__get_keys()
|
|
key.save_pub_key(path)
|
|
return salt.utils.fopen(path, 'r').read()
|
|
|
|
def get_mkey_paths(self):
|
|
return self.pub_path, self.rsa_path
|
|
|
|
def get_sign_paths(self):
|
|
return self.pub_sign_path, self.rsa_sign_path
|
|
|
|
def pubkey_signature(self):
|
|
'''
|
|
returns the base64 encoded signature from the signature file
|
|
or None if the master has its own signing keys
|
|
'''
|
|
return self.pub_signature
|
|
|
|
|
|
class Auth(object):
|
|
'''
|
|
The Auth class provides the sequence for setting up communication with
|
|
the master server from a minion.
|
|
'''
|
|
def __init__(self, opts):
|
|
'''
|
|
Init an Auth instance
|
|
|
|
:param dict opts: Options for this server
|
|
:return: Auth instance
|
|
:rtype: Auth
|
|
'''
|
|
self.opts = opts
|
|
self.token = Crypticle.generate_key_string()
|
|
self.serial = salt.payload.Serial(self.opts)
|
|
self.pub_path = os.path.join(self.opts['pki_dir'], 'minion.pub')
|
|
self.rsa_path = os.path.join(self.opts['pki_dir'], 'minion.pem')
|
|
if 'syndic_master' in self.opts:
|
|
self.mpub = 'syndic_master.pub'
|
|
elif 'alert_master' in self.opts:
|
|
self.mpub = 'monitor_master.pub'
|
|
else:
|
|
self.mpub = 'minion_master.pub'
|
|
if not os.path.isfile(self.pub_path):
|
|
self.get_keys()
|
|
|
|
def get_keys(self):
|
|
'''
|
|
Return keypair object for the minion.
|
|
|
|
:rtype: M2Crypto.RSA.RSA
|
|
:return: The RSA keypair
|
|
'''
|
|
# Make sure all key parent directories are accessible
|
|
user = self.opts.get('user', 'root')
|
|
salt.utils.verify.check_path_traversal(self.opts['pki_dir'], user)
|
|
|
|
if os.path.exists(self.rsa_path):
|
|
key = RSA.load_key(self.rsa_path)
|
|
log.debug('Loaded minion key: {0}'.format(self.rsa_path))
|
|
else:
|
|
log.info('Generating keys: {0}'.format(self.opts['pki_dir']))
|
|
gen_keys(self.opts['pki_dir'],
|
|
'minion',
|
|
self.opts['keysize'],
|
|
self.opts.get('user'))
|
|
key = RSA.load_key(self.rsa_path)
|
|
return key
|
|
|
|
def gen_token(self, clear_tok):
|
|
'''
|
|
Encrypt a string with the minion private key to verify identity
|
|
with the master.
|
|
|
|
:param str clear_tok: A plaintext token to encrypt
|
|
:return: Encrypted token
|
|
:rtype: str
|
|
'''
|
|
return self.get_keys().private_encrypt(clear_tok, 5)
|
|
|
|
def minion_sign_in_payload(self):
|
|
'''
|
|
Generates the payload used to authenticate with the master
|
|
server. This payload consists of the passed in id_ and the ssh
|
|
public key to encrypt the AES key sent back form the master.
|
|
|
|
:return: Payload dictionary
|
|
:rtype: dict
|
|
'''
|
|
payload = {}
|
|
payload['enc'] = 'clear'
|
|
payload['load'] = {}
|
|
payload['load']['cmd'] = '_auth'
|
|
payload['load']['id'] = self.opts['id']
|
|
try:
|
|
pub = RSA.load_pub_key(
|
|
os.path.join(self.opts['pki_dir'], self.mpub)
|
|
)
|
|
payload['load']['token'] = pub.public_encrypt(self.token, RSA.pkcs1_oaep_padding)
|
|
except Exception:
|
|
pass
|
|
with salt.utils.fopen(self.pub_path, 'r') as fp_:
|
|
payload['load']['pub'] = fp_.read()
|
|
return payload
|
|
|
|
def decrypt_aes(self, payload, master_pub=True):
|
|
'''
|
|
This function is used to decrypt the AES seed phrase returned from
|
|
the master server. The seed phrase is decrypted with the SSH RSA
|
|
host key.
|
|
|
|
Pass in the encrypted AES key.
|
|
Returns the decrypted AES seed key, a string
|
|
|
|
:param dict payload: The incoming payload. This is a dictionary which may have the following keys:
|
|
'aes': The shared AES key
|
|
'enc': The format of the message. ('clear', 'pub', etc)
|
|
'publish_port': The TCP port which published the message
|
|
'token': The encrypted token used to verify the message.
|
|
'pub_key': The public key of the sender.
|
|
|
|
:rtype: str
|
|
:return: The decrypted token that was provided, with padding.
|
|
|
|
:rtype: str
|
|
:return: The decrypted AES seed key
|
|
'''
|
|
if self.opts.get('auth_trb', False):
|
|
log.warning(
|
|
'Auth Called: {0}'.format(
|
|
''.join(traceback.format_stack())
|
|
)
|
|
)
|
|
else:
|
|
log.debug('Decrypting the current master AES key')
|
|
key = self.get_keys()
|
|
key_str = key.private_decrypt(payload['aes'], RSA.pkcs1_oaep_padding)
|
|
if 'sig' in payload:
|
|
m_path = os.path.join(self.opts['pki_dir'], self.mpub)
|
|
if os.path.exists(m_path):
|
|
try:
|
|
mkey = RSA.load_pub_key(m_path)
|
|
except Exception:
|
|
return '', ''
|
|
digest = hashlib.sha256(key_str).hexdigest()
|
|
m_digest = mkey.public_decrypt(payload['sig'], 5)
|
|
if m_digest != digest:
|
|
return '', ''
|
|
else:
|
|
return '', ''
|
|
if '_|-' in key_str:
|
|
return key_str.split('_|-')
|
|
else:
|
|
if 'token' in payload:
|
|
token = key.private_decrypt(payload['token'], RSA.pkcs1_oaep_padding)
|
|
return key_str, token
|
|
elif not master_pub:
|
|
return key_str, ''
|
|
return '', ''
|
|
|
|
def verify_pubkey_sig(self, message, sig):
|
|
'''
|
|
Wraps the verify_signature method so we have
|
|
additional checks.
|
|
|
|
:rtype: bool
|
|
:return: Success or failure of public key verification
|
|
'''
|
|
if self.opts['master_sign_key_name']:
|
|
path = os.path.join(self.opts['pki_dir'],
|
|
self.opts['master_sign_key_name'] + '.pub')
|
|
|
|
if os.path.isfile(path):
|
|
res = verify_signature(path,
|
|
message,
|
|
binascii.a2b_base64(sig))
|
|
else:
|
|
log.error('Verification public key {0} does not exist. You '
|
|
'need to copy it from the master to the minions '
|
|
'pki directory'.format(os.path.basename(path)))
|
|
return False
|
|
if res:
|
|
log.debug('Successfully verified signature of master '
|
|
'public key with verification public key '
|
|
'{0}'.format(self.opts['master_sign_key_name'] + '.pub'))
|
|
return True
|
|
else:
|
|
log.debug('Failed to verify signature of public key')
|
|
return False
|
|
else:
|
|
log.error('Failed to verify the signature of the message because '
|
|
'the verification key-pairs name is not defined. Please '
|
|
'make sure that master_sign_key_name is defined.')
|
|
return False
|
|
|
|
def verify_signing_master(self, payload):
|
|
try:
|
|
if self.verify_pubkey_sig(payload['pub_key'],
|
|
payload['pub_sig']):
|
|
log.info('Received signed and verified master pubkey '
|
|
'from master {0}'.format(self.opts['master']))
|
|
m_pub_fn = os.path.join(self.opts['pki_dir'], self.mpub)
|
|
salt.utils.fopen(m_pub_fn, 'w+').write(payload['pub_key'])
|
|
return True
|
|
else:
|
|
log.error('Received signed public-key from master {0} '
|
|
'but signature verification failed!'.format(self.opts['master']))
|
|
return False
|
|
except Exception as sign_exc:
|
|
log.error('There was an error while verifying the masters public-key signature')
|
|
raise Exception(sign_exc)
|
|
|
|
def check_auth_deps(self, payload):
|
|
'''
|
|
Checks if both master and minion either sign (master) and
|
|
verify (minion). If one side does not, it should fail.
|
|
|
|
:param dict payload: The incoming payload. This is a dictionary which may have the following keys:
|
|
'aes': The shared AES key
|
|
'enc': The format of the message. ('clear', 'pub', 'aes')
|
|
'publish_port': The TCP port which published the message
|
|
'token': The encrypted token used to verify the message.
|
|
'pub_key': The RSA public key of the sender.
|
|
'''
|
|
# master and minion sign and verify
|
|
if 'pub_sig' in payload and self.opts['verify_master_pubkey_sign']:
|
|
return True
|
|
# master and minion do NOT sign and do NOT verify
|
|
elif 'pub_sig' not in payload and not self.opts['verify_master_pubkey_sign']:
|
|
return True
|
|
|
|
# master signs, but minion does NOT verify
|
|
elif 'pub_sig' in payload and not self.opts['verify_master_pubkey_sign']:
|
|
log.error('The masters sent its public-key signature, but signature '
|
|
'verification is not enabled on the minion. Either enable '
|
|
'signature verification on the minion or disable signing '
|
|
'the public key on the master!')
|
|
return False
|
|
# master does NOT sign but minion wants to verify
|
|
elif 'pub_sig' not in payload and self.opts['verify_master_pubkey_sign']:
|
|
log.error('The master did not send its public-key signature, but '
|
|
'signature verification is enabled on the minion. Either '
|
|
'disable signature verification on the minion or enable '
|
|
'signing the public on the master!')
|
|
return False
|
|
|
|
def extract_aes(self, payload, master_pub=True):
|
|
'''
|
|
Return the AES key received from the master after the minion has been
|
|
successfully authenticated.
|
|
|
|
:param dict payload: The incoming payload. This is a dictionary which may have the following keys:
|
|
'aes': The shared AES key
|
|
'enc': The format of the message. ('clear', 'pub', etc)
|
|
'publish_port': The TCP port which published the message
|
|
'token': The encrypted token used to verify the message.
|
|
'pub_key': The RSA public key of the sender.
|
|
|
|
:rtype: str
|
|
:return: The shared AES key received from the master.
|
|
'''
|
|
if master_pub:
|
|
try:
|
|
aes, token = self.decrypt_aes(payload, master_pub)
|
|
if token != self.token:
|
|
log.error(
|
|
'The master failed to decrypt the random minion token'
|
|
)
|
|
return ''
|
|
except Exception:
|
|
log.error(
|
|
'The master failed to decrypt the random minion token'
|
|
)
|
|
return ''
|
|
return aes
|
|
else:
|
|
aes, token = self.decrypt_aes(payload, master_pub)
|
|
return aes
|
|
|
|
def verify_master(self, payload):
|
|
'''
|
|
Verify that the master is the same one that was previously accepted.
|
|
|
|
:param dict payload: The incoming payload. This is a dictionary which may have the following keys:
|
|
'aes': The shared AES key
|
|
'enc': The format of the message. ('clear', 'pub', etc)
|
|
'publish_port': The TCP port which published the message
|
|
'token': The encrypted token used to verify the message.
|
|
'pub_key': The RSA public key of the sender.
|
|
|
|
:rtype: str
|
|
:return: An empty string on verification failure. On success, the decrypted AES message in the payload.
|
|
'''
|
|
m_pub_fn = os.path.join(self.opts['pki_dir'], self.mpub)
|
|
if os.path.isfile(m_pub_fn) and not self.opts['open_mode']:
|
|
local_master_pub = salt.utils.fopen(m_pub_fn).read()
|
|
|
|
if payload['pub_key'] != local_master_pub:
|
|
if not self.check_auth_deps(payload):
|
|
return ''
|
|
|
|
if self.opts['verify_master_pubkey_sign']:
|
|
if self.verify_signing_master(payload):
|
|
return self.extract_aes(payload, master_pub=False)
|
|
else:
|
|
return ''
|
|
else:
|
|
# This is not the last master we connected to
|
|
log.error('The master key has changed, the salt master could '
|
|
'have been subverted, verify salt master\'s public '
|
|
'key')
|
|
return ''
|
|
|
|
else:
|
|
if not self.check_auth_deps(payload):
|
|
return ''
|
|
# verify the signature of the pubkey even if it has
|
|
# not changed compared with the one we already have
|
|
if self.opts['always_verify_signature']:
|
|
if self.verify_signing_master(payload):
|
|
return self.extract_aes(payload)
|
|
else:
|
|
log.error('The masters public could not be verified. Is the '
|
|
'verification pubkey {0} up to date?'
|
|
''.format(self.opts['master_sign_key_name'] + '.pub'))
|
|
return ''
|
|
|
|
else:
|
|
return self.extract_aes(payload)
|
|
else:
|
|
if not self.check_auth_deps(payload):
|
|
return ''
|
|
|
|
# verify the masters pubkey signature if the minion
|
|
# has not received any masters pubkey before
|
|
if self.opts['verify_master_pubkey_sign']:
|
|
if self.verify_signing_master(payload):
|
|
return self.extract_aes(payload, master_pub=False)
|
|
else:
|
|
return ''
|
|
# the minion has not received any masters pubkey yet, write
|
|
# the newly received pubkey to minion_master.pub
|
|
else:
|
|
salt.utils.fopen(m_pub_fn, 'w+').write(payload['pub_key'])
|
|
return self.extract_aes(payload, master_pub=False)
|
|
|
|
def sign_in(self, timeout=60, safe=True, tries=1):
|
|
'''
|
|
Send a sign in request to the master, sets the key information and
|
|
returns a dict containing the master publish interface to bind to
|
|
and the decrypted aes key for transport decryption.
|
|
|
|
:param int timeout: Number of seconds to wait before timing out the sign-in request
|
|
:param bool safe: If True, do not raise an exception on timeout. Retry instead.
|
|
:param int tries: The number of times to try to authenticate before giving up.
|
|
|
|
:raises SaltReqTimeoutError: If the sign-in request has timed out and :param safe: is not set
|
|
|
|
:return: Return a string on failure indicating the reason for failure. On success, return a dictionary
|
|
with the publication port and the shared AES key.
|
|
|
|
'''
|
|
auth = {}
|
|
|
|
auth_timeout = self.opts.get('auth_timeout', None)
|
|
if auth_timeout is not None:
|
|
timeout = auth_timeout
|
|
auth_safemode = self.opts.get('auth_safemode', None)
|
|
if auth_safemode is not None:
|
|
safe = auth_safemode
|
|
auth_tries = self.opts.get('auth_tries', None)
|
|
if auth_tries is not None:
|
|
tries = auth_tries
|
|
|
|
m_pub_fn = os.path.join(self.opts['pki_dir'], self.mpub)
|
|
|
|
sreq = salt.payload.SREQ(
|
|
self.opts['master_uri'],
|
|
)
|
|
|
|
try:
|
|
payload = sreq.send_auto(
|
|
self.minion_sign_in_payload(),
|
|
tries=tries,
|
|
timeout=timeout
|
|
)
|
|
except SaltReqTimeoutError as e:
|
|
if safe:
|
|
log.warning('SaltReqTimeoutError: {0}'.format(e))
|
|
return 'retry'
|
|
raise SaltClientError('Attempt to authenticate with the salt master failed')
|
|
|
|
if 'load' in payload:
|
|
if 'ret' in payload['load']:
|
|
if not payload['load']['ret']:
|
|
if self.opts['rejected_retry']:
|
|
log.error(
|
|
'The Salt Master has rejected this minion\'s public '
|
|
'key.\nTo repair this issue, delete the public key '
|
|
'for this minion on the Salt Master.\nThe Salt '
|
|
'Minion will attempt to to re-authenicate.'
|
|
)
|
|
return 'retry'
|
|
else:
|
|
log.critical(
|
|
'The Salt Master has rejected this minion\'s public '
|
|
'key!\nTo repair this issue, delete the public key '
|
|
'for this minion on the Salt Master and restart this '
|
|
'minion.\nOr restart the Salt Master in open mode to '
|
|
'clean out the keys. The Salt Minion will now exit.'
|
|
)
|
|
sys.exit(salt.defaults.exitcodes.EX_OK)
|
|
# has the master returned that its maxed out with minions?
|
|
elif payload['load']['ret'] == 'full':
|
|
return 'full'
|
|
else:
|
|
log.error(
|
|
'The Salt Master has cached the public key for this '
|
|
'node, this salt minion will wait for {0} seconds '
|
|
'before attempting to re-authenticate'.format(
|
|
self.opts['acceptance_wait_time']
|
|
)
|
|
)
|
|
return 'retry'
|
|
auth['aes'] = self.verify_master(payload)
|
|
if not auth['aes']:
|
|
log.critical(
|
|
'The Salt Master server\'s public key did not authenticate!\n'
|
|
'The master may need to be updated if it is a version of Salt '
|
|
'lower than {0}, or\n'
|
|
'If you are confident that you are connecting to a valid Salt '
|
|
'Master, then remove the master public key and restart the '
|
|
'Salt Minion.\nThe master public key can be found '
|
|
'at:\n{1}'.format(salt.version.__version__, m_pub_fn)
|
|
)
|
|
sys.exit(42)
|
|
if self.opts.get('syndic_master', False): # Is syndic
|
|
syndic_finger = self.opts.get('syndic_finger', self.opts.get('master_finger', False))
|
|
if syndic_finger:
|
|
if salt.utils.pem_finger(m_pub_fn) != syndic_finger:
|
|
self._finger_fail(syndic_finger, m_pub_fn)
|
|
else:
|
|
if self.opts.get('master_finger', False):
|
|
if salt.utils.pem_finger(m_pub_fn) != self.opts['master_finger']:
|
|
self._finger_fail(self.opts['master_finger'], m_pub_fn)
|
|
auth['publish_port'] = payload['publish_port']
|
|
return auth
|
|
|
|
def _finger_fail(self, finger, master_key):
|
|
log.critical(
|
|
'The specified fingerprint in the master configuration '
|
|
'file:\n{0}\nDoes not match the authenticating master\'s '
|
|
'key:\n{1}\nVerify that the configured fingerprint '
|
|
'matches the fingerprint of the correct master and that '
|
|
'this minion is not subject to a man-in-the-middle attack.'
|
|
.format(
|
|
finger,
|
|
salt.utils.pem_finger(master_key)
|
|
)
|
|
)
|
|
sys.exit(42)
|
|
|
|
|
|
class Crypticle(object):
|
|
'''
|
|
Authenticated encryption class
|
|
|
|
Encryption algorithm: AES-CBC
|
|
Signing algorithm: HMAC-SHA256
|
|
'''
|
|
|
|
PICKLE_PAD = 'pickle::'
|
|
AES_BLOCK_SIZE = 16
|
|
SIG_SIZE = hashlib.sha256().digest_size
|
|
|
|
def __init__(self, opts, key_string, key_size=192):
|
|
self.keys = self.extract_keys(key_string, key_size)
|
|
self.key_size = key_size
|
|
self.serial = salt.payload.Serial(opts)
|
|
|
|
@classmethod
|
|
def generate_key_string(cls, key_size=192):
|
|
key = os.urandom(key_size // 8 + cls.SIG_SIZE)
|
|
return key.encode('base64').replace('\n', '')
|
|
|
|
@classmethod
|
|
def extract_keys(cls, key_string, key_size):
|
|
key = key_string.decode('base64')
|
|
assert len(key) == key_size / 8 + cls.SIG_SIZE, 'invalid key'
|
|
return key[:-cls.SIG_SIZE], key[-cls.SIG_SIZE:]
|
|
|
|
def encrypt(self, data):
|
|
'''
|
|
encrypt data with AES-CBC and sign it with HMAC-SHA256
|
|
'''
|
|
aes_key, hmac_key = self.keys
|
|
pad = self.AES_BLOCK_SIZE - len(data) % self.AES_BLOCK_SIZE
|
|
data = data + pad * chr(pad)
|
|
iv_bytes = os.urandom(self.AES_BLOCK_SIZE)
|
|
cypher = AES.new(aes_key, AES.MODE_CBC, iv_bytes)
|
|
data = iv_bytes + cypher.encrypt(data)
|
|
sig = hmac.new(hmac_key, data, hashlib.sha256).digest()
|
|
return data + sig
|
|
|
|
def decrypt(self, data):
|
|
'''
|
|
verify HMAC-SHA256 signature and decrypt data with AES-CBC
|
|
'''
|
|
aes_key, hmac_key = self.keys
|
|
sig = data[-self.SIG_SIZE:]
|
|
data = data[:-self.SIG_SIZE]
|
|
mac_bytes = hmac.new(hmac_key, data, hashlib.sha256).digest()
|
|
if len(mac_bytes) != len(sig):
|
|
log.debug('Failed to authenticate message')
|
|
raise AuthenticationError('message authentication failed')
|
|
result = 0
|
|
for zipped_x, zipped_y in zip(mac_bytes, sig):
|
|
result |= ord(zipped_x) ^ ord(zipped_y)
|
|
if result != 0:
|
|
log.debug('Failed to authenticate message')
|
|
raise AuthenticationError('message authentication failed')
|
|
iv_bytes = data[:self.AES_BLOCK_SIZE]
|
|
data = data[self.AES_BLOCK_SIZE:]
|
|
cypher = AES.new(aes_key, AES.MODE_CBC, iv_bytes)
|
|
data = cypher.decrypt(data)
|
|
return data[:-ord(data[-1])]
|
|
|
|
def dumps(self, obj):
|
|
'''
|
|
Serialize and encrypt a python object
|
|
'''
|
|
return self.encrypt(self.PICKLE_PAD + self.serial.dumps(obj))
|
|
|
|
def loads(self, data):
|
|
'''
|
|
Decrypt and un-serialize a python object
|
|
'''
|
|
data = self.decrypt(data)
|
|
# simple integrity check to verify that we got meaningful data
|
|
if not data.startswith(self.PICKLE_PAD):
|
|
return {}
|
|
return self.serial.loads(data[len(self.PICKLE_PAD):])
|
|
|
|
|
|
class SAuth(Auth):
|
|
'''
|
|
Set up an object to maintain the standalone authentication session
|
|
with the salt master
|
|
'''
|
|
def __init__(self, opts):
|
|
super(SAuth, self).__init__(opts)
|
|
self.crypticle = self.__authenticate()
|
|
|
|
def __authenticate(self):
|
|
'''
|
|
Authenticate with the master, this method breaks the functional
|
|
paradigm, it will update the master information from a fresh sign
|
|
in, signing in can occur as often as needed to keep up with the
|
|
revolving master AES key.
|
|
|
|
:rtype: Crypticle
|
|
:returns: A crypticle used for encryption operations
|
|
'''
|
|
acceptance_wait_time = self.opts['acceptance_wait_time']
|
|
acceptance_wait_time_max = self.opts['acceptance_wait_time_max']
|
|
if not acceptance_wait_time_max:
|
|
acceptance_wait_time_max = acceptance_wait_time
|
|
|
|
while True:
|
|
creds = self.sign_in()
|
|
if creds == 'retry':
|
|
if self.opts.get('caller'):
|
|
print('Minion failed to authenticate with the master, '
|
|
'has the minion key been accepted?')
|
|
sys.exit(2)
|
|
if acceptance_wait_time:
|
|
log.info('Waiting {0} seconds before retry.'.format(acceptance_wait_time))
|
|
time.sleep(acceptance_wait_time)
|
|
if acceptance_wait_time < acceptance_wait_time_max:
|
|
acceptance_wait_time += acceptance_wait_time
|
|
log.debug('Authentication wait time is {0}'.format(acceptance_wait_time))
|
|
continue
|
|
break
|
|
return Crypticle(self.opts, creds['aes'])
|