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