diff --git a/man/homectl.xml b/man/homectl.xml index d3cb378e0e..41d7ce52b5 100644 --- a/man/homectl.xml +++ b/man/homectl.xml @@ -692,6 +692,17 @@ + + + + + Attempts to write the generated recovery key to the specified file. The file is + created with access mode 0600 and must not exist yet. This option may only be used + together with . If the file cannot be written, a warning is shown + and the account operation still succeeds. The recovery key is always shown on screen. + + + diff --git a/shell-completion/bash/homectl b/shell-completion/bash/homectl index 8976245fda..5cf09363e5 100644 --- a/shell-completion/bash/homectl +++ b/shell-completion/bash/homectl @@ -62,6 +62,8 @@ _homectl() { --language --ssh-authorized-keys --pkcs11-token-uri + --recovery-key + --recovery-key-file --locked --not-before --not-after @@ -126,7 +128,7 @@ _homectl() { --machine|-M) comps=$( __get_machines ) ;; - --identity|--image-path|--avatar|--login-background) + --identity|--image-path|--avatar|--login-background|--recovery-key-file) comps=$(compgen -A file -- "$cur" ) compopt -o filenames ;; @@ -136,7 +138,7 @@ _homectl() { --export-format) comps='full stripped minimal' ;; - --locked|--enforce-password-policy|--password-change-now|--luks-discard|--luks-offline-discard|--nosuid|--nodev|--noexec|--kill-processes|--auto-login) + --locked|--recovery-key|--enforce-password-policy|--password-change-now|--luks-discard|--luks-offline-discard|--nosuid|--nodev|--noexec|--kill-processes|--auto-login) comps='yes no' ;; -d|--home-dir|--skel|-b|--blob) diff --git a/src/home/homectl-recovery-key.c b/src/home/homectl-recovery-key.c index e6b5a80ce6..f8f2381ab9 100644 --- a/src/home/homectl-recovery-key.c +++ b/src/home/homectl-recovery-key.c @@ -1,16 +1,26 @@ /* SPDX-License-Identifier: LGPL-2.1-or-later */ +#include +#include +#include +#include + #include "ansi-color.h" +#include "chase.h" #include "errno-util.h" +#include "fd-util.h" #include "glyph-util.h" #include "homectl-recovery-key.h" +#include "io-util.h" #include "json-util.h" #include "libcrypt-util.h" #include "log.h" #include "memory-util.h" +#include "path-util.h" #include "qrcode-util.h" #include "recovery-key.h" #include "strv.h" +#include "tmpfile-util.h" static int add_privileged(sd_json_variant **v, const char *hashed) { _cleanup_(sd_json_variant_unrefp) sd_json_variant *e = NULL, *w = NULL, *l = NULL; @@ -103,11 +113,12 @@ static int add_secret(sd_json_variant **v, const char *password) { return 0; } -int identity_add_recovery_key(sd_json_variant **v) { +int identity_add_recovery_key(sd_json_variant **v, char **ret_recovery_key) { _cleanup_(erase_and_freep) char *password = NULL, *hashed = NULL; int r; assert(v); + assert(ret_recovery_key); /* First, let's generate a secret key */ r = make_recovery_key(&password); @@ -134,6 +145,13 @@ int identity_add_recovery_key(sd_json_variant **v) { if (r < 0) return r; + *ret_recovery_key = TAKE_PTR(password); + return 0; +} + +void show_recovery_key(const char *recovery_key) { + assert(recovery_key); + /* We output the key itself with a trailing newline to stdout and the decoration around it to stderr * instead. */ @@ -146,7 +164,7 @@ int identity_add_recovery_key(sd_json_variant **v) { ansi_highlight()); fflush(stderr); - fputs(password, stdout); + fputs(recovery_key, stdout); fflush(stdout); fputs(ansi_normal(), stderr); @@ -161,7 +179,48 @@ int identity_add_recovery_key(sd_json_variant **v) { "whenever authentication is requested.\n", stderr); fflush(stderr); - (void) print_qrcode(stderr, "Optionally scan the recovery key for safekeeping", password); + (void) print_qrcode(stderr, "Optionally scan the recovery key for safekeeping", recovery_key); +} +int recovery_key_file_write(const char *path, const char *recovery_key) { + _cleanup_free_ char *filename = NULL, *tmp = NULL; + _cleanup_close_ int dir_fd = -EBADF, fd = -EBADF; + int r; + + assert(path); + assert(recovery_key); + + r = chase(path, + /* root= */ NULL, + CHASE_PARENT|CHASE_EXTRACT_FILENAME|CHASE_PROHIBIT_SYMLINKS, + &filename, + &dir_fd); + if (r < 0) + return r; + if (!filename_is_valid(filename)) + return -EINVAL; + + fd = open_tmpfile_linkable_at(dir_fd, filename, O_WRONLY|O_CLOEXEC, &tmp); + if (fd < 0) + return fd; + + CLEANUP_TMPFILE_AT(dir_fd, tmp); + + if (fchmod(fd, 0600) < 0) + return -errno; + + r = loop_write(fd, recovery_key, strlen(recovery_key)); + if (r < 0) + return r; + + r = loop_write(fd, "\n", 1); + if (r < 0) + return r; + + r = link_tmpfile_at(fd, dir_fd, tmp, filename, LINK_TMPFILE_SYNC); + if (r < 0) + return r; + + tmp = mfree(tmp); /* disarm CLEANUP_TMPFILE_AT() */ return 0; } diff --git a/src/home/homectl-recovery-key.h b/src/home/homectl-recovery-key.h index 5a48f5fec1..82ef39df6f 100644 --- a/src/home/homectl-recovery-key.h +++ b/src/home/homectl-recovery-key.h @@ -3,4 +3,6 @@ #include "forward.h" -int identity_add_recovery_key(sd_json_variant **v); +int identity_add_recovery_key(sd_json_variant **v, char **ret_recovery_key); +void show_recovery_key(const char *recovery_key); +int recovery_key_file_write(const char *path, const char *recovery_key); diff --git a/src/home/homectl.c b/src/home/homectl.c index e5c2857134..929a3a1ccb 100644 --- a/src/home/homectl.c +++ b/src/home/homectl.c @@ -106,6 +106,7 @@ static int arg_fido2_cred_alg = COSE_ES256; static int arg_fido2_cred_alg = 0; #endif static bool arg_recovery_key = false; +static char *arg_recovery_key_file = NULL; static sd_json_format_flags_t arg_json_format_flags = SD_JSON_FORMAT_OFF; static ExportFormat arg_export_format = EXPORT_FORMAT_FULL; static uint64_t arg_capability_bounding_set = CAP_MASK_UNSET; @@ -131,6 +132,7 @@ STATIC_DESTRUCTOR_REGISTER(arg_identity_filter, strv_freep); STATIC_DESTRUCTOR_REGISTER(arg_identity_filter_rlimits, strv_freep); STATIC_DESTRUCTOR_REGISTER(arg_pkcs11_token_uri, strv_freep); STATIC_DESTRUCTOR_REGISTER(arg_fido2_device, strv_freep); +STATIC_DESTRUCTOR_REGISTER(arg_recovery_key_file, freep); STATIC_DESTRUCTOR_REGISTER(arg_blob_dir, freep); STATIC_DESTRUCTOR_REGISTER(arg_blob_files, hashmap_freep); STATIC_DESTRUCTOR_REGISTER(arg_key_name, freep); @@ -157,6 +159,7 @@ static bool identity_properties_specified(void) { !strv_isempty(arg_identity_filter_rlimits) || !strv_isempty(arg_pkcs11_token_uri) || !strv_isempty(arg_fido2_device) || + arg_recovery_key || arg_blob_dir || arg_blob_clear || !hashmap_isempty(arg_blob_files); @@ -1016,12 +1019,14 @@ static int add_disposition(sd_json_variant **v) { return 1; } -static int acquire_new_home_record(sd_json_variant *input, UserRecord **ret) { +static int acquire_new_home_record(sd_json_variant *input, UserRecord **ret, char **ret_recovery_key) { _cleanup_(sd_json_variant_unrefp) sd_json_variant *v = NULL; + _cleanup_(erase_and_freep) char *recovery_key = NULL; _cleanup_(user_record_unrefp) UserRecord *hr = NULL; int r; assert(ret); + assert(ret_recovery_key); if (arg_identity) { unsigned line = 0, column = 0; @@ -1062,7 +1067,7 @@ static int acquire_new_home_record(sd_json_variant *input, UserRecord **ret) { } if (arg_recovery_key) { - r = identity_add_recovery_key(&v); + r = identity_add_recovery_key(&v, &recovery_key); if (r < 0) return r; } @@ -1094,6 +1099,7 @@ static int acquire_new_home_record(sd_json_variant *input, UserRecord **ret) { return r; *ret = TAKE_PTR(hr); + *ret_recovery_key = TAKE_PTR(recovery_key); return 0; } @@ -1316,13 +1322,31 @@ static int bus_message_append_blobs(sd_bus_message *m, Hashmap *blobs) { return sd_bus_message_close_container(m); } +static void finalize_recovery_key(const char *recovery_key, const char *recovery_key_file) { + int r; + + if (!recovery_key) + return; + + if (recovery_key_file) { + r = recovery_key_file_write(recovery_key_file, recovery_key); + if (r < 0) + log_warning_errno(r, + "Failed to write recovery key file '%s', ignoring: %m", + recovery_key_file); + } + + show_recovery_key(recovery_key); +} + static int create_home_common(sd_json_variant *input, bool show_enforce_password_policy_hint) { _cleanup_(sd_bus_flush_close_unrefp) sd_bus *bus = NULL; _cleanup_(user_record_unrefp) UserRecord *hr = NULL; _cleanup_hashmap_free_ Hashmap *blobs = NULL; + _cleanup_(erase_and_freep) char *recovery_key = NULL; int r; - r = acquire_new_home_record(input, &hr); + r = acquire_new_home_record(input, &hr, &recovery_key); if (r < 0) return r; @@ -1369,6 +1393,7 @@ static int create_home_common(sd_json_variant *input, bool show_enforce_password if (arg_dry_run) { sd_json_variant_dump(hr->json, SD_JSON_FORMAT_COLOR_AUTO|SD_JSON_FORMAT_PRETTY_AUTO|SD_JSON_FORMAT_NEWLINE, stderr, /* prefix= */ NULL); + finalize_recovery_key(recovery_key, /* recovery_key_file= */ NULL); return 0; } @@ -1430,6 +1455,7 @@ static int create_home_common(sd_json_variant *input, bool show_enforce_password break; /* done */ } + finalize_recovery_key(recovery_key, arg_recovery_key_file); return 0; } @@ -1473,13 +1499,16 @@ static int verb_create_home(int argc, char *argv[], uintptr_t _data, void *userd static int acquire_updated_home_record( sd_bus *bus, const char *username, - UserRecord **ret) { + UserRecord **ret, + char **ret_recovery_key) { _cleanup_(sd_json_variant_unrefp) sd_json_variant *json = NULL; + _cleanup_(erase_and_freep) char *recovery_key = NULL; _cleanup_(user_record_unrefp) UserRecord *hr = NULL; int r; assert(ret); + assert(ret_recovery_key); if (arg_identity) { unsigned line = 0, column = 0; @@ -1561,7 +1590,7 @@ static int acquire_updated_home_record( } if (arg_recovery_key) { - r = identity_add_recovery_key(&json); + r = identity_add_recovery_key(&json, &recovery_key); if (r < 0) return r; } @@ -1584,6 +1613,7 @@ static int acquire_updated_home_record( return r; *ret = TAKE_PTR(hr); + *ret_recovery_key = TAKE_PTR(recovery_key); return 0; } @@ -1617,6 +1647,7 @@ static int verb_update_home(int argc, char *argv[], uintptr_t _data, void *userd _cleanup_(sd_bus_flush_close_unrefp) sd_bus *bus = NULL; _cleanup_(user_record_unrefp) UserRecord *hr = NULL, *secret = NULL; _cleanup_free_ char *buffer = NULL; + _cleanup_(erase_and_freep) char *recovery_key = NULL; _cleanup_hashmap_free_ Hashmap *blobs = NULL; const char *username; uint64_t flags = 0; @@ -1642,7 +1673,7 @@ static int verb_update_home(int argc, char *argv[], uintptr_t _data, void *userd (void) polkit_agent_open_if_enabled(arg_transport, arg_ask_password); - r = acquire_updated_home_record(bus, username, &hr); + r = acquire_updated_home_record(bus, username, &hr, &recovery_key); if (r < 0) return r; @@ -1661,6 +1692,7 @@ static int verb_update_home(int argc, char *argv[], uintptr_t _data, void *userd if (arg_dry_run) { sd_json_variant_dump(hr->json, SD_JSON_FORMAT_COLOR_AUTO|SD_JSON_FORMAT_PRETTY_AUTO|SD_JSON_FORMAT_NEWLINE, stderr, /* prefix= */ NULL); + finalize_recovery_key(recovery_key, /* recovery_key_file= */ NULL); return 0; } @@ -1716,6 +1748,8 @@ static int verb_update_home(int argc, char *argv[], uintptr_t _data, void *userd break; } + finalize_recovery_key(recovery_key, arg_recovery_key_file); + if (and_resize) log_info("Resizing home."); @@ -4370,6 +4404,12 @@ static int parse_argv(int argc, char *argv[], char ***remaining_args) { return r; break; + OPTION_LONG("recovery-key-file", "PATH", "Write the generated recovery key to a file"): + r = parse_path_argument(opts.arg, /* suppress_root= */ false, &arg_recovery_key_file); + if (r < 0) + return r; + break; + OPTION_GROUP("Blob Directory User Record Properties"): {} OPTION('b', "blob", "[FILENAME=]PATH", @@ -4877,6 +4917,9 @@ static int parse_argv(int argc, char *argv[], char ***remaining_args) { } } + if (arg_recovery_key_file && !arg_recovery_key) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "--recovery-key-file= requires --recovery-key=yes."); + *remaining_args = option_parser_get_args(&opts); return 1; } diff --git a/test/units/TEST-46-HOMED.sh b/test/units/TEST-46-HOMED.sh index aaa40455d1..ee0b249db0 100755 --- a/test/units/TEST-46-HOMED.sh +++ b/test/units/TEST-46-HOMED.sh @@ -267,6 +267,76 @@ testcase_basic() { homectl remove test-user } +testcase_recovery_key_file() ( + local KEY_DIR + KEY_DIR="$(mktemp -d /tmp/homed-recovery-key-file.XXXXXX)" + local KEY_FILE="$KEY_DIR/recovery-key" + local KEY_FILE2="$KEY_DIR/recovery-key-update" + local DRY_RUN_OUTPUT="$KEY_DIR/dry-run.out" + local DRY_RUN_ERROR="$KEY_DIR/dry-run.err" + local UPDATE_EXISTING_OUTPUT="$KEY_DIR/update-existing.out" + local KEY_FILE_CONTENT + + homectl remove recoverykeyfiletest 2>/dev/null || true + + trap 'homectl remove recoverykeyfiletest 2>/dev/null || true; rm -rf "$KEY_DIR"' EXIT ERR + + (! NEWPASSWORD=Secr3tRecovery \ + homectl create recoverykeyfiletest \ + --storage=directory \ + --recovery-key-file="$KEY_FILE" \ + --enforce-password-policy=no ) + + SYSTEMD_HOME_DRY_RUN=1 \ + NEWPASSWORD=Secr3tRecovery \ + homectl create recoverykeyfiletest \ + --storage=directory \ + --recovery-key=yes \ + --recovery-key-file="$KEY_FILE" \ + --enforce-password-policy=no \ + >"$DRY_RUN_OUTPUT" \ + 2>"$DRY_RUN_ERROR" + grep -E '^[cbdefghijklnrtuv]{8}(-[cbdefghijklnrtuv]{8}){7}$' "$DRY_RUN_OUTPUT" >/dev/null + test ! -e "$KEY_FILE" + + NEWPASSWORD=Secr3tRecovery \ + homectl create recoverykeyfiletest \ + --storage=directory \ + --recovery-key=yes \ + --recovery-key-file="$KEY_FILE" \ + --rate-limit-interval=1s \ + --rate-limit-burst=1000 \ + --enforce-password-policy=no + + test -s "$KEY_FILE" + [[ "$(stat -c "%a" "$KEY_FILE")" == "600" ]] + grep -E '^[cbdefghijklnrtuv]{8}(-[cbdefghijklnrtuv]{8}){7}$' "$KEY_FILE" >/dev/null + PASSWORD="$(cat "$KEY_FILE")" homectl authenticate recoverykeyfiletest + + KEY_FILE_CONTENT="$(cat "$KEY_FILE")" + PASSWORD=Secr3tRecovery \ + homectl update recoverykeyfiletest \ + --recovery-key=yes \ + --recovery-key-file="$KEY_FILE" \ + >"$UPDATE_EXISTING_OUTPUT" + [[ "$(cat "$KEY_FILE")" == "$KEY_FILE_CONTENT" ]] + grep -E '^[cbdefghijklnrtuv]{8}(-[cbdefghijklnrtuv]{8}){7}$' "$UPDATE_EXISTING_OUTPUT" >/dev/null + PASSWORD="$(cat "$UPDATE_EXISTING_OUTPUT")" homectl authenticate recoverykeyfiletest + + PASSWORD=Secr3tRecovery \ + homectl update recoverykeyfiletest \ + --recovery-key=yes \ + --recovery-key-file="$KEY_FILE2" + + test -s "$KEY_FILE2" + [[ "$(stat -c "%a" "$KEY_FILE2")" == "600" ]] + grep -E '^[cbdefghijklnrtuv]{8}(-[cbdefghijklnrtuv]{8}){7}$' "$KEY_FILE2" >/dev/null + PASSWORD="$(cat "$KEY_FILE2")" homectl authenticate recoverykeyfiletest + + homectl remove recoverykeyfiletest + rm -rf "$KEY_DIR" +) + testcase_blob() { # blob directory tests # See docs/USER_RECORD_BLOB_DIRS.md @@ -1115,6 +1185,7 @@ testcase_identity_groups() { machinectl shell idgrouptest@ /usr/bin/bash -euxo pipefail -c "jq '.memberOf = ((.memberOf // []) + [\"systemd-journal\"] | unique) | .lastChangeUSec = ((.lastChangeUSec // 0) + 3600000000)' /home/idgrouptest/.identity > /home/idgrouptest/.identity.new && mv -f /home/idgrouptest/.identity.new /home/idgrouptest/.identity" jq -e '.memberOf | index("systemd-journal") != null' /home/idgrouptest/.identity + wait_for_state idgrouptest active PASSWORD=foobar homectl authenticate idgrouptest