From: Eric Biggers <ebiggers@xxxxxxxxxx> Introduce a function _verify_ciphertext_for_encryption_policy() which verifies the correctness of encryption with the specified settings. Basically, it does the following: 1. If missing any prerequisites, skip the test. 2. Create files in encrypted directories on the scratch device. 3. Unmount the scratch device and compare the actual ciphertext stored on-disk to the ciphertext computed by the fscrypt-crypt-util program. Both file contents and names are verified, and non-default encryption modes are supported. Previously, non-default encryption modes were untested by xfstests. Also, while there's an existing test generic/399 that checks that encrypted contents seem random, it doesn't actually test for correctness, nor does it test filenames encryption. Signed-off-by: Eric Biggers <ebiggers@xxxxxxxxxx> --- common/encrypt | 392 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 392 insertions(+) diff --git a/common/encrypt b/common/encrypt index a4ffc531..13098d7f 100644 --- a/common/encrypt +++ b/common/encrypt @@ -258,3 +258,395 @@ _get_encpolicy() $XFS_IO_PROG -c "get_encpolicy $*" "$file" } + +# Retrieve the encryption nonce of the given inode as a hex string. The nonce +# was randomly generated by the filesystem and isn't exposed directly to +# userspace. But it can be read using the filesystem's debugging tools. +_get_encryption_nonce() +{ + local device=$1 + local inode=$2 + + case $FSTYP in + ext4) + # Use debugfs to dump the special xattr named "c", which is the + # file's fscrypt_context. This produces a line like: + # + # c (28) = 01 01 04 02 00 00 00 00 00 00 00 00 ef bd 18 76 5d f6 41 4e c0 a2 cd 5f 91 29 7e 12 + # + # Then filter it to get just the 16-byte 'nonce' field at the end: + # + # efbd18765df6414ec0a2cd5f91297e12 + # + $DEBUGFS_PROG $device -R "ea_get <$inode> c" 2>>$seqres.full \ + | grep '^c ' | sed 's/^.*=//' | tr -d ' \n' | tail -c 32 + ;; + f2fs) + # dump.f2fs prints the fscrypt_context like: + # + # xattr: e_name_index:9 e_name:c e_name_len:1 e_value_size:28 e_value: + # format: 1 + # contents_encryption_mode: 0x1 + # filenames_encryption_mode: 0x4 + # flags: 0x2 + # master_key_descriptor: 0000000000000000 + # nonce: EFBD18765DF6414EC0A2CD5F91297E12 + $DUMP_F2FS_PROG -i $inode $device | awk ' + /\<e_name:c\>/ { found = 1 } + /^nonce:/ && found { + print substr($0, length($0) - 31, 32); + found = 0; + }' + ;; + *) + _fail "_get_encryption_nonce() isn't implemented on $FSTYP" + ;; + esac +} + +# Require support for _get_encryption_nonce() +_require_get_encryption_nonce_support() +{ + echo "Checking for _get_encryption_nonce() support for $FSTYP" >> $seqres.full + case $FSTYP in + ext4) + _require_command "$DEBUGFS_PROG" debugfs + ;; + f2fs) + _require_command "$DUMP_F2FS_PROG" dump.f2fs + ;; + *) + _notrun "_get_encryption_nonce() isn't implemented on $FSTYP" + ;; + esac +} + +# Retrieve the encrypted filename stored on-disk for the given file. +# The name is printed to stdout in binary. +_get_ciphertext_filename() +{ + local device=$1 + local inode=$2 + local dir_inode=$3 + + case $FSTYP in + ext4) + # Extract the filename from the debugfs output line like: + # + # 131075 100644 (1) 0 0 0 22-Apr-2019 16:54 \xa2\x85\xb0z\x13\xe9\x09\x86R\xed\xdc\xce\xad\x14d\x19 + # + # Bytes are shown either literally or as \xHH-style escape + # sequences; we have to decode the escaped bytes here. + # + $DEBUGFS_PROG $device -R "ls -l -r <$dir_inode>" \ + 2>>$seqres.full | perl -ne ' + next if not /^\s*'$inode'\s+/; + s/.*?\d\d:\d\d //; + chomp; + s/\\x([[:xdigit:]]{2})/chr hex $1/eg; + print;' + ;; + f2fs) + # Extract the filename from the dump.f2fs output line like: + # + # i_name [UpkzIPuts9by1oDmE+Ivfw] + # + # The name is shown base64-encoded; we have to decode it here. + # + $DUMP_F2FS_PROG $device -i $inode | perl -ne ' + next if not /^i_name\s+\[([A-Za-z0-9+,]+)\]/; + chomp $1; + my @chars = split //, $1; + my $ac = 0; + my $bits = 0; + my $table = join "", (A..Z, a..z, 0..9, "+", ","); + foreach (@chars) { + $ac += index($table, $_) << $bits; + $bits += 6; + if ($bits >= 8) { + print chr($ac & 0xff); + $ac >>= 8; + $bits -= 8; + } + } + if ($ac != 0) { + print STDERR "Invalid base64-encoded string!\n"; + }' + ;; + *) + _fail "_get_ciphertext_filename() isn't implemented on $FSTYP" + ;; + esac +} + +# Require support for _get_ciphertext_filename(). +_require_get_ciphertext_filename_support() +{ + echo "Checking for _get_ciphertext_filename() support for $FSTYP" >> $seqres.full + case $FSTYP in + ext4) + # Verify that the "ls -l -r" debugfs command is supported and + # that it hex-encodes non-ASCII characters, rather than using an + # ambiguous escaping method. This requires e2fsprogs v1.45.1 or + # later; or more specifically, a version that has the commit + # "debugfs: avoid ambiguity when printing filenames". + _require_command "$DEBUGFS_PROG" debugfs + _scratch_mount + touch $SCRATCH_MNT/$'\xc1' + _scratch_unmount + if ! $DEBUGFS_PROG $SCRATCH_DEV -R "ls -l -r /" 2>&1 \ + | tee -a $seqres.full | grep -E -q '\s+\\xc1\s*$'; then + _notrun "debugfs (e2fsprogs) is too old; doesn't support showing unambiguous on-disk filenames" + fi + ;; + f2fs) + # Verify that dump.f2fs shows encrypted filenames in full. This + # requires f2fs-tools v1.13.0 or later; or more specifically, a + # version that has the commit + # "f2fs-tools: improve filename printing". + + _require_command "$DUMP_F2FS_PROG" dump.f2fs + _require_command "$KEYCTL_PROG" keyctl + _scratch_mount + _new_session_keyring + + local keydesc=$(_generate_encryption_key) + local dir=$SCRATCH_MNT/test.${FUNCNAME[0]} + local file=$dir/$(perl -e 'print "A" x 255') + mkdir $dir + _set_encpolicy $dir $keydesc + touch $file + local inode=$(stat -c %i $file) + + _scratch_unmount + $KEYCTL_PROG clear @s + + # 255-character filename should result in 340 base64 characters. + if ! $DUMP_F2FS_PROG -i $inode $SCRATCH_DEV \ + | grep -E -q '^i_name[[:space:]]+\[[A-Za-z0-9+,]{340}\]'; then + _notrun "dump.f2fs (f2fs-tools) is too old; doesn't support showing unambiguous on-disk filenames" + fi + ;; + *) + _notrun "_get_ciphertext_filename() isn't implemented on $FSTYP" + ;; + esac +} + +# Get an encrypted file's list of on-disk blocks as a comma-separated list of +# block offsets from the start of the device. "Blocks" are 512 bytes each here. +_get_ciphertext_block_list() +{ + local file=$1 + + sync + $XFS_IO_PROG -c fiemap $file | perl -ne ' + next if not /^\s*\d+: \[\d+\.\.\d+\]: (\d+)\.\.(\d+)/; + print $_ . "," foreach $1..$2;' | sed 's/,$//' +} + +# Dump a block list that was previously saved by _get_ciphertext_block_list(). +_dump_ciphertext_blocks() +{ + local device=$1 + local blocklist=$2 + local block + + for block in $(tr ',' ' ' <<< $blocklist); do + dd if=$device bs=512 count=1 skip=$block status=none + done +} + +_do_verify_ciphertext_for_encryption_policy() +{ + local contents_encryption_mode=$1 + local filenames_encryption_mode=$2 + local policy_flags=$3 + local set_encpolicy_args=$4 + local keydesc=$5 + local raw_key_hex=$6 + local crypt_cmd="src/fscrypt-crypt-util $7" + + local blocksize=$(_get_block_size $SCRATCH_MNT) + local test_contents_files=() + local test_filenames_files=() + local i src dir dst inode blocklist \ + padding_flag padding dir_inode len name f nonce decrypted_name + + # Create files whose encrypted contents we'll verify. For each, save + # the information: (copy of original file, inode number of encrypted + # file, comma-separated block list) into test_contents_files[]. + echo "Creating files for contents verification" >> $seqres.full + i=1 + rm -f $tmp.testfile_* + for src in /dev/zero /dev/urandom; do + head -c $((4 * blocksize)) $src > $tmp.testfile_$i + (( i++ )) + done + dir=$SCRATCH_MNT/encdir + mkdir $dir + _set_encpolicy $dir $keydesc $set_encpolicy_args -f $policy_flags + for src in $tmp.testfile_*; do + dst=$dir/${src##*.} + cp $src $dst + inode=$(stat -c %i $dst) + blocklist=$(_get_ciphertext_block_list $dst) + test_contents_files+=("$src $inode $blocklist") + done + + # Create files whose encrypted names we'll verify. For each, save the + # information: (original filename, inode number of encrypted file, inode + # of parent directory, padding amount) into test_filenames_files[]. Try + # each padding amount: 4, 8, 16, or 32 bytes. Also try various filename + # lengths, including boundary cases. Assume NAME_MAX == 255. + echo "Creating files for filenames verification" >> $seqres.full + for padding_flag in 0 1 2 3; do + padding=$((4 << padding_flag)) + dir=$SCRATCH_MNT/encdir.pad$padding + mkdir $dir + dir_inode=$(stat -c %i $dir) + _set_encpolicy $dir $keydesc $set_encpolicy_args \ + -f $((policy_flags | padding_flag)) + for len in 1 3 15 16 17 32 100 254 255; do + name=$(tr -d -C a-zA-Z0-9 < /dev/urandom | head -c $len) + touch $dir/$name + inode=$(stat -c %i $dir/$name) + test_filenames_files+=("$name $inode $dir_inode $padding") + done + done + + # Now unmount the filesystem and verify the ciphertext we just wrote. + _scratch_unmount + + echo "Verifying encrypted file contents" >> $seqres.full + for f in "${test_contents_files[@]}"; do + read -r src inode blocklist <<< "$f" + nonce=$(_get_encryption_nonce $SCRATCH_DEV $inode) + _dump_ciphertext_blocks $SCRATCH_DEV $blocklist > $tmp.actual_contents + $crypt_cmd $contents_encryption_mode $raw_key_hex \ + --file-nonce=$nonce --block-size=$blocksize \ + < $src > $tmp.expected_contents + if ! cmp $tmp.expected_contents $tmp.actual_contents; then + _fail "Expected encrypted contents != actual encrypted contents. File: $f" + fi + $crypt_cmd $contents_encryption_mode $raw_key_hex --decrypt \ + --file-nonce=$nonce --block-size=$blocksize \ + < $tmp.actual_contents > $tmp.decrypted_contents + if ! cmp $src $tmp.decrypted_contents; then + _fail "Contents decryption sanity check failed. File: $f" + fi + done + + echo "Verifying encrypted file names" >> $seqres.full + for f in "${test_filenames_files[@]}"; do + read -r name inode dir_inode padding <<< "$f" + nonce=$(_get_encryption_nonce $SCRATCH_DEV $dir_inode) + _get_ciphertext_filename $SCRATCH_DEV $inode $dir_inode \ + > $tmp.actual_name + echo -n "$name" | \ + $crypt_cmd $filenames_encryption_mode $raw_key_hex \ + --file-nonce=$nonce --padding=$padding \ + --block-size=255 > $tmp.expected_name + if ! cmp $tmp.expected_name $tmp.actual_name; then + _fail "Expected encrypted filename != actual encrypted filename. File: $f" + fi + $crypt_cmd $filenames_encryption_mode $raw_key_hex --decrypt \ + --file-nonce=$nonce --padding=$padding \ + --block-size=255 < $tmp.actual_name \ + > $tmp.decrypted_name + decrypted_name=$(tr -d '\0' < $tmp.decrypted_name) + if [ "$name" != "$decrypted_name" ]; then + _fail "Filename decryption sanity check failed ($name != $decrypted_name). File: $f" + fi + done +} + +_fscrypt_mode_name_to_num() +{ + local name=$1 + + case "$name" in + AES-256-XTS) echo 1 ;; # FS_ENCRYPTION_MODE_AES_256_XTS + AES-256-CTS-CBC) echo 4 ;; # FS_ENCRYPTION_MODE_AES_256_CTS + AES-128-CBC-ESSIV) echo 5 ;; # FS_ENCRYPTION_MODE_AES_128_CBC + AES-128-CTS-CBC) echo 6 ;; # FS_ENCRYPTION_MODE_AES_128_CTS + Adiantum) echo 9 ;; # FS_ENCRYPTION_MODE_ADIANTUM + *) _fail "Unknown fscrypt mode: $name" ;; + esac +} + +# Verify that file contents and names are encrypted correctly when an encryption +# policy of the specified type is used. +# +# The first two parameters are the contents and filenames encryption modes to +# test. Optionally, also specify 'direct' to test the DIRECT_KEY flag. +_verify_ciphertext_for_encryption_policy() +{ + local contents_encryption_mode=$1 + local filenames_encryption_mode=$2 + local opt + local policy_flags=0 + local set_encpolicy_args="" + local crypt_util_args="" + + shift 2 + for opt; do + case "$opt" in + direct) + if [ $contents_encryption_mode != \ + $filenames_encryption_mode ]; then + _fail "For direct key mode, contents and filenames modes must match" + fi + (( policy_flags |= 0x04 )) # FS_POLICY_FLAG_DIRECT_KEY + ;; + *) + _fail "Unknown option '$opt' passed to ${FUNCNAME[0]}" + ;; + esac + done + local contents_mode_num=$(_fscrypt_mode_name_to_num $contents_encryption_mode) + local filenames_mode_num=$(_fscrypt_mode_name_to_num $filenames_encryption_mode) + + set_encpolicy_args+=" -c $contents_mode_num" + set_encpolicy_args+=" -n $filenames_mode_num" + + if (( policy_flags & 0x04 )); then + crypt_util_args+=" --kdf=none" + else + crypt_util_args+=" --kdf=AES-128-ECB" + fi + set_encpolicy_args=${set_encpolicy_args# } + + _require_scratch_encryption $set_encpolicy_args + _require_test_program "fscrypt-crypt-util" + _require_xfs_io_command "fiemap" + _require_get_encryption_nonce_support + _require_get_ciphertext_filename_support + _require_command "$KEYCTL_PROG" keyctl + + echo "Creating encryption-capable filesystem" >> $seqres.full + _scratch_mkfs_encrypted &>> $seqres.full + _scratch_mount + + echo "Generating encryption key" >> $seqres.full + local raw_key=$(_generate_raw_encryption_key) + local keydesc=$(_generate_key_descriptor) + _new_session_keyring + _add_encryption_key $keydesc $raw_key + local raw_key_hex=$(echo "$raw_key" | tr -d '\\x') + + echo + echo -e "Verifying ciphertext with parameters:" + echo -e "\tcontents_encryption_mode: $contents_encryption_mode" + echo -e "\tfilenames_encryption_mode: $filenames_encryption_mode" + [ $# -ne 0 ] && echo -e "\toptions: $*" + + _do_verify_ciphertext_for_encryption_policy \ + "$contents_encryption_mode" \ + "$filenames_encryption_mode" \ + "$policy_flags" \ + "$set_encpolicy_args" \ + "$keydesc" \ + "$raw_key_hex" \ + "$crypt_util_args" +} -- 2.22.0.rc1.257.g3120a18244-goog