Commit 15496926 authored by Robin Schneider's avatar Robin Schneider

Enhance docs, wrote UnauthenticatedLatencyChecker, support key by UUID

parent 34ce0c87
Pipeline #7354180 passed with stages
in 4 minutes
......@@ -18,7 +18,7 @@ Added
- Add Python 3 support, documentation, CI, Python package, platform
independences, unit testing. [ypid_]
- Add ``LinkLayerAddressChecker`` and ``AuthenticatedLatencyChecker``.
- Wrote checkers: ``LinkLayerAddressChecker``, ``UnauthenticatedLatencyChecker``, ``AuthenticatedLatencyChecker``.
Refer to :ref:`fdeunlock__ref_host_checkers` for details. [ypid_]
- Add configurable :ref:`config_start_command <fdeunlock__ref_config_start_command>`
......
......@@ -18,22 +18,24 @@ Checkout the following example:
::
fdeunlock --host fde-server.example.org-initramfs
INFO, 2017-03-29 10:27:41,822: Host offline. Attempting to start using: virsh -c qemu:///system start crypttest
Domain crypttest started
INFO, 2017-03-29 10:27:41,822: Host offline. Attempting to start using: virsh -c qemu:///system start fde-server
Domain fde-server started
INFO, 2017-03-29 10:27:42,726: Start command returned with: 0
INFO, 2017-03-29 10:27:48,257: Host offline. Waiting …
INFO, 2017-03-29 10:27:53,264: Ping result: 198.51.100.23 : [0], 84 bytes, 0.51 ms (0.51 avg, 0% loss)
INFO, 2017-03-29 10:27:53,269: Running Network based checkers: LinkLayerAddressChecker
INFO, 2017-03-29 10:27:53,270: Running Network based checkers: LinkLayerAddressChecker, UnauthenticatedLatencyChecker
INFO, 2017-03-29 10:27:53,273: Link layer address matches the trusted once.
INFO, 2017-03-29 10:27:54,396: SSH session to initramfs established.
INFO, 2017-03-29 10:27:54,396: Running SSH based checkers: ChecksumChecker, AuthenticatedLatencyChecker
INFO, 2017-03-29 10:27:53,283: ICMP ping round trip time: 0.7300 ms
INFO, 2017-03-29 10:27:53,283: Latency is within the boundaries.
INFO, 2017-03-29 10:27:54,296: SSH session to initramfs established.
INFO, 2017-03-29 10:27:54,296: Running SSH based checkers: ChecksumChecker, AuthenticatedLatencyChecker
INFO, 2017-03-29 10:27:57,487: Checksums match the trusted once.
INFO, 2017-03-29 10:27:57,559: Latency to execute a command over SSH and get the response back: 0.0716 s
INFO, 2017-03-29 10:27:57,560: Trusted latency: 0.060256694030762
INFO, 2017-03-29 10:27:57,560: Current latency: 0.07161283493041992
INFO, 2017-03-29 10:27:57,559: Latency to execute a command over SSH and get the response back: 71.6000 ms
INFO, 2017-03-29 10:27:57,560: Trusted latency: 60.256694030762
INFO, 2017-03-29 10:27:57,560: Current latency: 71.61283493041992
Choose one of 'save', 'ignore' (for current run) or anything else to exit: save
INFO, 2017-03-29 10:28:02,739: All 3 checks passed.
INFO, 2017-03-29 10:28:02,739: All 4 checks passed.
INFO, 2017-03-29 10:28:02,820: Passing key for vda3_crypt to host fde-server.example.org-initramfs.
INFO, 2017-03-29 10:28:05,140: Could not retrieve key for vdb3_crypt (host fde-server.example.org-initramfs).
Please enter key for vdb3_crypt (or store it in a vault):
......
......@@ -19,6 +19,11 @@ There are also lots of additional resources available on how to set this up:
* http://www.lug-hh.de/wp-content/uploads/kwi_cloudserver_01_fde_0.3.pdf
* https://www.reddit.com/r/linuxadmin/comments/3ot1xk/headless_server_with_fdeluks/
Note that FDEunlock makes use of the :command:`cryptroot-unlock` script which
is only available in the `cryptsetup package`_ of Debian stretch or newer.
FDEunlock includes the script for now to make it work out-of-the-box with older
Debian releases and potentially other GNU/Linux distributions.
FDEunlock has been successfully tested in the following configurations:
* Debian jessie, dropbear 2014.65-1: IPv4 only, IPv6 only, dual stack
......@@ -90,11 +95,42 @@ Providing a key using the default FileVault
-------------------------------------------
Place your key (either the passphrase or the keyfile) into
``${FDEUNLOCK_CONFIG_DIR}/keys/${host}_${device_name}.key``. When you use a
passphrase you will need to ensure that no newline is appended to the file (all
common editors appended a newline automatically). One way to avoid the newline
is to run the following command:
``${FDEUNLOCK_CONFIG_DIR}/keys/${host}_${device_name}.key``.
``${device_name}`` is either the plaintext device mapper target or the full
ciphertext block device path with ``/`` replaced with ``_``.
Note that the later variant depends on your :file:`/etc/crypttab`
configuration.
Consider this example :file:`/etc/crypttab` file:
.. code-block:: none
sda4_crypt /dev/disk/by-partuuid/e1cd49d2-158b-11e7-99d8-00163e5e6c0f none luks
Where ``sda4_crypt`` is the plaintext device mapper target of your root
filesystem. The following two ``${device_name}`` can be used here:
* ``sda4_crypt``
* ``dev_disk_by-partuuid_e1cd49d2-158b-11e7-99d8-00163e5e6c0f``
If both files exist the first one (more generic) is tested first and might need
to be removed if it does not contain the correct password.
The later, more explicit variant (using GPT partition UUIDs in this example) is
generally preferred.
When you use a passphrase you will need to ensure that no newline is appended
to the file (all common editors appended a newline automatically). One way to
avoid the newline is to run the following command:
.. code-block:: shell
echo -n 'Please enter your passphrase: '; read -rs pw; echo -n "$pw" > "${key_file}"; unset pw
Alternatively, to generate a new passphrase you can run this command instead:
.. code-block:: shell
echo -n "$(pwgen -s 123 1)" > "${key_file}"
......@@ -31,10 +31,40 @@ Network based checkers
Note that the link layer address is typically capturable and spoofable in a
local network.
.. _fdeunlock__ref_UnauthenticatedLatencyChecker:
``UnauthenticatedLatencyChecker``
Checks the round trip time measured by :command:`fping` based on one ICMP
packet if it is within expected boundaries.
The default boundaries is 1.0 ms and can be configured using the
``unauthenticated_latency_deviation`` configuration option which is a float
number representing the time deviation in ms.
The intention of this check together with the
:ref:`AuthenticatedLatencyChecker <fdeunlock__ref_AuthenticatedLatencyChecker>`
is to detect a variant of an `evil maid attack`_ where the host you think you
are just unlocking is not the one you are actually unlocking.
Such an attack might have different latency characteristics because even the
most advanced adversary is still bound by the law of physics.
For reference, the speed of light is 300 km/ms.
SSH based checkers
------------------
.. _fdeunlock__ref_AuthenticatedLatencyChecker:
``AuthenticatedLatencyChecker``
Measure the latency over SSH and check if it is within expected boundaries.
The default boundaries are 10.0 ms and can be configured using the
``authenticated_latency_deviation`` configuration option which is a float
number representing the time deviation in ms.
Refer to :ref:`UnauthenticatedLatencyChecker
<fdeunlock__ref_UnauthenticatedLatencyChecker>` for the background.
.. _fdeunlock__ref_ChecksumChecker:
``ChecksumChecker``
......@@ -65,34 +95,24 @@ SSH based checkers
dmesg | egrep '(DMI:|Command line:|Booting paravirtualized kernel on bare hardware)' | sed 's/^\[\s*[[:digit:].]\+\]\s*//;'
cat /sys/devices/virtual/dmi/id/board_serial /sys/devices/virtual/dmi/id/product_uuid
ls /dev/disk/by-id/
grep '^MemTotal:' /proc/meminfo
dd if=/dev/sda bs=512 count=1 | sha512sum -
The ``diff_command`` configuration option can be used to set a different text
diffing program than the default `diff`.
Comparison is run on your local machine so note that the diffing program is
exposed to untrusted input.
.. You might want to add 2>/dev/zero to dd: Remove race condition between output of sha512sum and dd.
The ``diff_command`` configuration option can be used to set another text
diffing program than the default :command:`diff` command.
Comparison is run on your local machine. Note that the diffing program is
exposed to untrusted input. The files path to the trusted and the currently
untrusted checksum file are appended as the last two parameters to the given
command, in the mentioned order (trusted first; untrusted second/last).
Example:
.. code-block:: ini
[DEFAULT]
diff_command = git diff --color-words --no-index
diff_command = git diff --no-index
Proper remote attestation (Trusted Computing) should be implemented.
Feel free to add supported for this to FDEunlock :-)
Feel free to add support for this to FDEunlock :-)
Ref: https://security.stackexchange.com/questions/46548/for-remotely-unlocking-luks-volumes-via-ssh-how-can-i-verify-integrity-before-s
``AuthenticatedLatencyChecker``
Measure the latency over SSH and check if it is within expected boundaries.
The default boundaries are 0.01 s (10 ms) and can be configured using the
``authenticated_latency_deviation`` configuration option.
The intention of this check is to detect a variant of an evil maid attack where
the host you think you are just unlocking is not the one you are actually
unlocking.
Such an attack might have different latency characteristics because even the
most advanced adversary is still bound by the law of physics like the speed
of light.
......@@ -7,3 +7,5 @@
.. _configparser: https://docs.python.org/3/library/configparser.html
.. _cloc: https://github.com/AlDanial/cloc
.. _Verifying PyPI and Conda Packages: http://stuartmumford.uk/blog/verifying-pypi-and-conda-packages.html
.. _cryptsetup package: https://packages.debian.org/search?keywords=cryptsetup
.. _Debian cryptsetup package: https://packages.debian.org/search?keywords=cryptsetup
......@@ -21,7 +21,12 @@ from abc import ABC, abstractmethod
import filecmp
from hexdump import hexdump
__all__ = ['LinkLayerAddressChecker', 'ChecksumChecker', 'AuthenticatedLatencyChecker']
__all__ = [
'LinkLayerAddressChecker',
'UnauthenticatedLatencyChecker',
'ChecksumChecker',
'AuthenticatedLatencyChecker',
]
LOG = logging.getLogger(__name__)
......@@ -159,6 +164,57 @@ class LinkLayerAddressChecker(NetworkBasedChecker):
return False
class UnauthenticatedLatencyChecker(NetworkBasedChecker):
"""
Check the unauthenticated latency previously measured by fping if it is within expected boundaries.
"""
def __init__(self, unlocker):
self._unlocker = unlocker
def check(self, **kwargs):
untrusted_latency = self._unlocker._ping_rtt_avg
LOG.info("ICMP ping round trip time: {:.4f} ms".format(untrusted_latency))
original_host = self._unlocker._original_host
latency = self._unlocker._properties.getfloat(original_host, 'unauthenticated_latency', fallback=-1.0)
deviation = self._unlocker._cfg.getfloat(original_host, 'unauthenticated_latency_deviation')
if latency == -1.0:
LOG.info("No latency found to compare to. Trusting the current one (TOFU).")
self._unlocker._properties.set(original_host, 'unauthenticated_latency', str(untrusted_latency))
return True
elif latency-deviation <= untrusted_latency and untrusted_latency <= latency+deviation:
LOG.info("Latency is within the boundaries.")
return True
self._latency = latency
self._untrusted_latency = untrusted_latency
return False
def update(self):
LOG.info("Trusted latency: {} ms".format(
self._latency,
))
LOG.info("Current latency: {} ms".format(
self._untrusted_latency,
))
answer = input("Choose one of 'save', 'ignore' (for current run) or anything else to exit: ")
if answer == 'save':
original_host = self._unlocker._original_host
self._unlocker._properties.set(
original_host,
'unauthenticated_latency',
str(self._untrusted_latency),
)
return True
elif answer == 'ignore':
return True
else:
return False
class ChecksumChecker(SshBasedChecker):
"""
Compute checksums for all files in the initramfs and compare the checksums to previously measured trusted once.
......@@ -212,7 +268,11 @@ class ChecksumChecker(SshBasedChecker):
' -print0'
# 'find /root -type f -print0'
# ' | sort -z'
' | xargs -0 /root/hashdeep -r -c md5,sha1,sha256 | grep -v "^[#%]" | sort -t "," -k4'
' | xargs -0 /root/hashdeep -r -c md5,sha1,sha256 | grep -v "^[#%]"'
' | sort -t "," -k5'
# Make the path /root-DdbZw2/.ssh/authorized_keys filename deterministic
# by replacing it with /root-XXX/.ssh/authorized_keys
r' | sed "s#,/root-[[:alnum:]]\+/#,/root-XXX/#"'
)
shell.prompt()
for cmd in additional_checksum_commands:
......@@ -227,7 +287,7 @@ class ChecksumChecker(SshBasedChecker):
with open(self._untrusted_checksum_file, 'r') as untrusted_checksum_fh:
with open(self._untrusted_checksum_file + '.tmp', 'w') as untrusted_checksum_tmp_fh:
untrusted_checksum_tmp_fh.write(
re.sub(r'[^\w#/,.:~*\n|"\' _-]', '', untrusted_checksum_fh.read()))
re.sub(r'[^\w#/,.:~=*\n|"\' _-]', '', untrusted_checksum_fh.read()))
os.rename(self._untrusted_checksum_file + '.tmp', self._untrusted_checksum_file)
if not os.path.isfile(self._checksum_file):
......@@ -281,8 +341,8 @@ class AuthenticatedLatencyChecker(SshBasedChecker):
shell.expect(token)
shell.prompt()
end = time.time()
untrusted_latency = end - start
LOG.info("Latency to execute a command over SSH and get the response back: {:.4f} s".format(untrusted_latency))
untrusted_latency = (end - start) * 1000
LOG.info("Latency to execute a command over SSH and get the response back: {:.4f} ms".format(untrusted_latency))
original_host = self._unlocker._original_host
latency = self._unlocker._properties.getfloat(original_host, 'authenticated_latency', fallback=-1.0)
......@@ -300,10 +360,10 @@ class AuthenticatedLatencyChecker(SshBasedChecker):
return False
def update(self):
LOG.info("Trusted latency: {}".format(
LOG.info("Trusted latency: {} ms".format(
self._latency,
))
LOG.info("Current latency: {}".format(
LOG.info("Current latency: {} ms".format(
self._untrusted_latency,
))
answer = input("Choose one of 'save', 'ignore' (for current run) or anything else to exit: ")
......
#!/bin/busybox ash
# Remotely unlock encrypted volumes.
#
# Copyright © 2015 Guilhem Moulin <guilhem@guilhem.org>
#
# 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 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
set -ue
PATH=/sbin:/bin
TIMEOUT=10
PASSFIFO=/lib/cryptsetup/passfifo
ASKPASS=/lib/cryptsetup/askpass
# Return 0 if $pid has a file descriptor pointing to $name, and 1
# otherwise.
in_fds() {
local pid="$1" name="$2" fd
for fd in $(find "/proc/$pid/fd" -type l); do
[ "$(readlink -f "$fd")" != "$name" ] || return 0
done
return 1
}
# Print the PID of the askpass process with a file descriptor opened to
# /lib/cryptsetup/passfifo.
get_askpass_pid() {
ps -eo pid,args | sed -nr "s#^\s*([0-9]+)\s+$ASKPASS\s+.*#\1#p" | while read pid; do
if in_fds "$pid" "$PASSFIFO"; then
echo "$pid"
break
fi
done
}
# Wait for askpass, then set $PID (resp. $BIRTH) to the PID (resp.
# birth date) of the cryptsetup process with same $CRYPTTAB_NAME.
wait_for_prompt() {
local pid=$(get_askpass_pid) timer=$(( 10 * $TIMEOUT ))
# wait for the fifo
until [ "$pid" ] && [ -p "$PASSFIFO" ]; do
sleep .1
pid=$(get_askpass_pid)
timer=$(( $timer - 1 ))
if [ $timer -le 0 ]; then
echo "Error: Timeout reached while waiting for askpass." >&2
exit 1
fi
done
# find the cryptsetup process with same $CRYPTTAB_NAME
eval $(grep -Ez '^CRYPTTAB_(NAME|TRIED|SOURCE)=' "/proc/$pid/environ" | tr '\0' '\n')
for pid in $(ps -eo pid,args | sed -nr 's#^\s*([0-9]+)\s+/sbin/cryptsetup\s+.*#\1#p'); do
if grep -Fxqz "CRYPTTAB_NAME=$CRYPTTAB_NAME" "/proc/$pid/environ"; then
PID=$pid
BIRTH=$(stat -c'%Z' "/proc/$PID")
return 0;
fi
done
PID=
BIRTH=
}
# Wait until $PID no longer exists or has a birth date greater that
# $BIRTH (ie was reallocated). Then return with exit value 0 if
# /dev/mapper/$CRYPTTAB_NAME exists, and with exit value 1 if the
# maximum number of tries exceeded. Otherwise (if the unlocking
# failed), return with value 1.
wait_for_answer() {
local timer=$(( 10 * $TIMEOUT ))
until [ ! -d "/proc/$PID" ] || [ $(stat -c'%Z' "/proc/$PID") -gt $BIRTH ]; do
sleep .1
timer=$(( $timer - 1 ))
if [ $timer -le 0 ]; then
echo "Error: Timeout reached while waiting for PID $PID." >&2
exit 1
fi
done
if [ -e "/dev/mapper/$CRYPTTAB_NAME" ]; then
echo "cryptsetup: $CRYPTTAB_NAME set up successfully" >&2
exit 0
elif [ $CRYPTTAB_TRIED -ge 2 ]; then
echo "cryptsetup: maximum number of tries exceeded for $CRYPTTAB_NAME" >&2
exit 1
else
echo "cryptsetup: cryptsetup failed, bad password or options?" >&2
return 1
fi
}
if [ -t 0 ] && [ -x "$ASKPASS" ]; then
# interactive mode on a TTY: keep trying until successful or
# maximum number of tries exceeded.
while :; do
wait_for_prompt
diskname="$CRYPTTAB_NAME"
[ "${CRYPTTAB_SOURCE#/dev/disk/by-uuid/}" != "$CRYPTTAB_SOURCE" ] || diskname="$diskname ($CRYPTTAB_SOURCE)"
read -rs -p "Please unlock disk $diskname: "; echo
printf '%s' "$REPLY" >"$PASSFIFO"
wait_for_answer || true
done
else
# non-interactive mode: slurp the passphrase from stdin
wait_for_prompt
diskname="$CRYPTTAB_NAME"
[ "${CRYPTTAB_SOURCE#/dev/disk/by-uuid/}" != "$CRYPTTAB_SOURCE" ] || diskname="$diskname ($CRYPTTAB_SOURCE)"
echo "Please unlock disk $diskname"
cat >"$PASSFIFO"
wait_for_answer || exit 1
fi
......@@ -59,7 +59,7 @@ class FdeUnlock(object):
LOG.debug("SSH options: host: {}, port: {}".format(
self._original_host, port,
))
start_command = self._cfg.get(
start_command = str(self._cfg.get(
self._original_host, 'start_command',
vars={
'originalhost': self._original_host,
......@@ -68,7 +68,7 @@ class FdeUnlock(object):
'hostname': self._original_host.split('.')[0],
'domain': '.'.join(self._original_host.split('.')[1:]),
},
)
))
while True:
if self._is_reachable(host):
......@@ -159,6 +159,7 @@ class FdeUnlock(object):
if returncode == 0 and not hasattr(self, '_host_address'):
self._host_address = ping_status[0]
self._ping_rtt_avg = float(ping_status[4].split('/')[1])
LOG.debug("Set _host_address to {}.".format(self._host_address))
return returncode
......@@ -207,30 +208,59 @@ class FdeUnlock(object):
while True:
init_shell.sendline('cryptroot-unlock')
key_query_pattern = r'Please unlock disk (?P<device_name>[\w-]+):'
# Example: Please unlock disk sda4_crypt (/dev/disk/by-partuuid/3b014afe-1581-11e7-b65d-00163e5e6c0f):
key_query_pattern = r'Please unlock disk (?P<device_name0>[\w-]+)(?: \((?P<device_name1>[\w/-]+)\)):'
try:
init_shell.expect(key_query_pattern)
cryptroot_unlock_status = init_shell.expect([key_query_pattern, 'not found'])
except ExceptionPexpect:
break
if cryptroot_unlock_status == 1:
LOG.debug("cryptroot-unlock not present on remote host, copying the version that FDEunlock includes.")
cryptroot_unlock_file = os.path.join(
os.path.abspath(os.path.dirname(__file__)),
'data', 'cryptroot-unlock',
)
# /usr/bin is also in the $PATH of the initramfs but the dir
# does not exist by default and I am to lazy to create it :)
# Also, this might not work for other distros.
init_shell.copy_to_remote(cryptroot_unlock_file, '/bin/cryptroot-unlock')
init_shell.sendline('chmod +x /bin/cryptroot-unlock')
init_shell.prompt()
continue
key_query_re = re.match(key_query_pattern, init_shell.after)
device_name = key_query_re.group('device_name')
device_names = []
for device_ind in range(3):
try:
device_name = key_query_re.group('device_name{}'.format(device_ind))
except IndexError:
pass
else:
device_name = device_name.lstrip('/').replace('/', '_')
device_names.append(device_name)
# Bye, bye cryptroot-unlock, we can take it from here.
init_shell.sendcontrol('c')
init_shell.prompt()
key = self._vault.get_key(self._original_host, device_name)
key = None
for device_name in device_names:
key = self._vault.get_key(self._original_host, device_name)
if key is not None:
break
if key is None:
LOG.info("Could not retrieve key for {} (host {}).".format(
device_name,
', '.join(device_names),
self._original_host,
))
key = getpass("Please enter key for {} (or store it in a vault): ".format(
device_name,
', '.join(device_names),
)).encode('utf-8')
LOG.info("Passing key for {} to host {}.".format(
device_name,
', '.join(device_names),
self._original_host,
))
proc = subprocess.Popen(
......@@ -241,7 +271,7 @@ class FdeUnlock(object):
if proc.wait() != 0:
raise Exception(
'Could not pass key for {} to {}.'.format(
device_name,
', '.join(device_names),
self._original_host,
)
)
......
......@@ -59,7 +59,8 @@ def read_config():
'start_command_shell': 'False',
'additional_checksum_commands': '',
'diff_command': 'diff',
'authenticated_latency_deviation': '0.01',
'authenticated_latency_deviation': '10.0',
'unauthenticated_latency_deviation': '1.0',
})
cfg.read(cfg_files)
LOG.debug("Read configuration files: {}".format(cfg_files))
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment