homectl: write recovery key file via linkable tmpfile

Create the file with open_tmpfile_linkable_at().
Write the recovery key and trailing newline to the temporary fd.
Set the final mode to 0600 before publishing it.
Link it into place with link_tmpfile_at(..., LINK_TMPFILE_SYNC).
This commit is contained in:
dongshengyuan
2026-08-25 06:57:32 +09:00
committed by Yu Watanabe
parent 3f72568682
commit 471e34d5cb
6 changed files with 200 additions and 12 deletions
+11
View File
@@ -692,6 +692,17 @@
<xi:include href="version-info.xml" xpointer="v247"/></listitem>
</varlistentry>
<varlistentry>
<term><option>--recovery-key-file=<replaceable>PATH</replaceable></option></term>
<listitem><para>Attempts to write the generated recovery key to the specified file. The file is
created with access mode <literal>0600</literal> and must not exist yet. This option may only be used
together with <option>--recovery-key=yes</option>. If the file cannot be written, a warning is shown
and the account operation still succeeds. The recovery key is always shown on screen.</para>
<xi:include href="version-info.xml" xpointer="v262"/></listitem>
</varlistentry>
</variablelist>
</refsect1>
+4 -2
View File
@@ -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)
+62 -3
View File
@@ -1,16 +1,26 @@
/* SPDX-License-Identifier: LGPL-2.1-or-later */
#include <fcntl.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#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;
}
+3 -1
View File
@@ -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);
+49 -6
View File
@@ -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;
}
+71
View File
@@ -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