diff --git a/TODO b/TODO index b63b436e29..179d17e234 100644 --- a/TODO +++ b/TODO @@ -128,12 +128,6 @@ Features: defining subdir of /run/credentials/ where to place it. Set $CREDENTIAL_PATH env var for services to the result. Also pass via fd passing (optionally). -* homed: add native recovery key support. use 48 lowercase modhex characters - (192bit), show qr code of it, include pattern expression in user record. - -* homed: introduce "degraded" state for home directories that weren't cleanly - unmounted (use xattr we add and remove on the loop back file) - * homed: during login resize fs automatically towards size goal. Specifically, resize to diskSize if possible, but leave a certain amount (configured by a new value diskLeaveFreeSize) of space free on the backing fs. @@ -171,11 +165,6 @@ Features: * systemd-gpt-auto should probably set x-systemd.growfs on the mounts it creates -* homed/userdb: distinguish passwords and recovery keys in the records, since - we probably want to use different PBKDF algorithms/settings for them: - passwords have low entropy but recovery keys should have good entropy key - hence we can make them quicker to work. - * bootctl: - teach it to prepare an ESP wholesale, i.e. with mkfs.vfat invocation - teach it to copy in unified kernel images and maybe type #1 boot loader spec entries from host diff --git a/docs/USER_RECORD.md b/docs/USER_RECORD.md index 960a82d894..6cba02aa0c 100644 --- a/docs/USER_RECORD.md +++ b/docs/USER_RECORD.md @@ -553,6 +553,11 @@ credential ID that shell be used for authentication with FIDO2 devices that implement the `hmac-secret` extension. The salt to pass to the FIDO2 device is found in `fido2HmacSalt`. +`recoveryKeyType` → An array of strings, each indicating the type of one +recovery key. The only supported recovery key type at the moment is `modhex64`, +for details see the description of `recoveryKey` below. An account may have any +number of recovery keys defined, and the array should have one entry for each. + `privileged` → An object, which contains the fields of the `privileged` section of the user record, see below. @@ -632,6 +637,25 @@ matching one in `fido2HmacCredential`, and vice versa, with the same credential ID, appearing in the same order, but this should not be required by applications processing user records. +`recoveryKey`→ An array of objects, each defining a recovery key. The object +has two mandatory fields: `type` indicates the type of recovery key. The only +currently permitted value is the string `modhex64`. The `hashedPassword` field +contains a UNIX password hash of the normalized recovery key. Recovery keys are +in most ways similar to regular passwords, except that they are generated by +the computer, not chosen by the user, and are longer. Currently, the only +supported recovery key format is `modhex64`, which consists of 64 +[modhex](https://developers.yubico.com/yubico-c/Manuals/modhex.1.html) +characters (i.e. 256bit of information), in groups of 8 chars separated by +dashes, +e.g. `lhkbicdj-trbuftjv-tviijfck-dfvbknrh-uiulbhui-higltier-kecfhkbk-egrirkui`. Recovery +keys should be accepted wherever regular passwords are. The `recoveryKey` field +should always be accompanied by a `recoveryKeyType` field (see above), and each +entry in either should map 1:1 to an entry in the other, in the same order and +matching the type. When accepting a recovery key it should be brought +automatically into normalized form, i.e. the dashes inserted when missing, and +converted into lowercase before tested against the UNIX password hash, so that +recovery keys are effectively case-insensitive. + ## Fields in the `perMachine` section As mentioned, the `perMachine` section contains settings that shall apply to diff --git a/man/homectl.xml b/man/homectl.xml index f2e9879930..4c792ca8cb 100644 --- a/man/homectl.xml +++ b/man/homectl.xml @@ -376,6 +376,16 @@ discussion see above. + + BOOL + + Accepts a boolean argument. If enabled a recovery key is configured for the + account. A recovery key is a computer generated access key that may be used to regain access to an + account if the password has been forgotten or the authentication token lost. The key is generated and + shown on screen, and should be printed or otherwise transferred to a secure location. A recovery key + may be entered instead of a regular password to unlock the account. + + BOOLEAN diff --git a/meson.build b/meson.build index a42fbc1d6d..cd2b02488b 100644 --- a/meson.build +++ b/meson.build @@ -2196,7 +2196,8 @@ if conf.get('ENABLE_HOMED') == 1 libcrypt, libopenssl, libp11kit, - libfido2], + libfido2, + libdl], install_rpath : rootlibexecdir, install : true, install_dir : rootbindir) diff --git a/src/home/homectl-recovery-key.c b/src/home/homectl-recovery-key.c new file mode 100644 index 0000000000..9d7f345f1e --- /dev/null +++ b/src/home/homectl-recovery-key.c @@ -0,0 +1,253 @@ +/* SPDX-License-Identifier: LGPL-2.1+ */ + +#if HAVE_QRENCODE +#include +#include "qrcode-util.h" +#endif + +#include "dlfcn-util.h" +#include "errno-util.h" +#include "homectl-recovery-key.h" +#include "libcrypt-util.h" +#include "locale-util.h" +#include "memory-util.h" +#include "modhex.h" +#include "random-util.h" +#include "strv.h" +#include "terminal-util.h" + +static int make_recovery_key(char **ret) { + _cleanup_(erase_and_freep) char *formatted = NULL; + _cleanup_(erase_and_freep) uint8_t *key = NULL; + int r; + + assert(ret); + + key = new(uint8_t, MODHEX_RAW_LENGTH); + if (!key) + return log_oom(); + + r = genuine_random_bytes(key, MODHEX_RAW_LENGTH, RANDOM_BLOCK); + if (r < 0) + return log_error_errno(r, "Failed to gather entropy for recovery key: %m"); + + /* Let's now format it as 64 modhex chars, and after each 8 chars insert a dash */ + formatted = new(char, MODHEX_FORMATTED_LENGTH); + if (!formatted) + return log_oom(); + + for (size_t i = 0, j = 0; i < MODHEX_RAW_LENGTH; i++) { + formatted[j++] = modhex_alphabet[key[i] >> 4]; + formatted[j++] = modhex_alphabet[key[i] & 0xF]; + + if (i % 4 == 3) + formatted[j++] = '-'; + } + + formatted[MODHEX_FORMATTED_LENGTH-1] = 0; + + *ret = TAKE_PTR(formatted); + return 0; +} + +static int add_privileged(JsonVariant **v, const char *hashed) { + _cleanup_(json_variant_unrefp) JsonVariant *e = NULL, *w = NULL, *l = NULL; + int r; + + assert(v); + assert(hashed); + + r = json_build(&e, JSON_BUILD_OBJECT( + JSON_BUILD_PAIR("type", JSON_BUILD_STRING("modhex64")), + JSON_BUILD_PAIR("hashedPassword", JSON_BUILD_STRING(hashed)))); + if (r < 0) + return log_error_errno(r, "Failed to build recover key JSON object: %m"); + + json_variant_sensitive(e); + + w = json_variant_ref(json_variant_by_key(*v, "privileged")); + l = json_variant_ref(json_variant_by_key(w, "recoveryKey")); + + r = json_variant_append_array(&l, e); + if (r < 0) + return log_error_errno(r, "Failed append recovery key: %m"); + + r = json_variant_set_field(&w, "recoveryKey", l); + if (r < 0) + return log_error_errno(r, "Failed to set recovery key array: %m"); + + r = json_variant_set_field(v, "privileged", w); + if (r < 0) + return log_error_errno(r, "Failed to update privileged field: %m"); + + return 0; +} + +static int add_public(JsonVariant **v) { + _cleanup_strv_free_ char **types = NULL; + int r; + + assert(v); + + r = json_variant_strv(json_variant_by_key(*v, "recoveryKeyType"), &types); + if (r < 0) + return log_error_errno(r, "Failed to parse recovery key type list: %m"); + + r = strv_extend(&types, "modhex64"); + if (r < 0) + return log_oom(); + + r = json_variant_set_field_strv(v, "recoveryKeyType", types); + if (r < 0) + return log_error_errno(r, "Failed to update recovery key types: %m"); + + return 0; +} + +static int add_secret(JsonVariant **v, const char *password) { + _cleanup_(json_variant_unrefp) JsonVariant *w = NULL, *l = NULL; + _cleanup_(strv_free_erasep) char **passwords = NULL; + int r; + + assert(v); + assert(password); + + w = json_variant_ref(json_variant_by_key(*v, "secret")); + l = json_variant_ref(json_variant_by_key(w, "password")); + + r = json_variant_strv(l, &passwords); + if (r < 0) + return log_error_errno(r, "Failed to convert password array: %m"); + + r = strv_extend(&passwords, password); + if (r < 0) + return log_oom(); + + r = json_variant_new_array_strv(&l, passwords); + if (r < 0) + return log_error_errno(r, "Failed to allocate new password array JSON: %m"); + + json_variant_sensitive(l); + + r = json_variant_set_field(&w, "password", l); + if (r < 0) + return log_error_errno(r, "Failed to update password field: %m"); + + r = json_variant_set_field(v, "secret", w); + if (r < 0) + return log_error_errno(r, "Failed to update secret object: %m"); + + return 0; +} + +static int print_qr_code(const char *secret) { +#if HAVE_QRENCODE + QRcode* (*sym_QRcode_encodeString)(const char *string, int version, QRecLevel level, QRencodeMode hint, int casesensitive); + void (*sym_QRcode_free)(QRcode *qrcode); + _cleanup_(dlclosep) void *dl = NULL; + QRcode* qr; + int r; + + /* If this is not an UTF-8 system or ANSI colors aren't supported/disabled don't print any QR + * codes */ + if (!is_locale_utf8() || !colors_enabled()) + return -EOPNOTSUPP; + + dl = dlopen("libqrencode.so.4", RTLD_LAZY); + if (!dl) + return log_debug_errno(SYNTHETIC_ERRNO(EOPNOTSUPP), + "QRCODE support is not installed: %s", dlerror()); + + r = dlsym_many_and_warn( + dl, + LOG_DEBUG, + &sym_QRcode_encodeString, "QRcode_encodeString", + &sym_QRcode_free, "QRcode_free", + NULL); + if (r < 0) + return r; + + qr = sym_QRcode_encodeString(secret, 0, QR_ECLEVEL_L, QR_MODE_8, 0); + if (!qr) + return -ENOMEM; + + fprintf(stderr, "\nYou may optionally scan the recovery key off screen:\n\n"); + + write_qrcode(stderr, qr); + + fputc('\n', stderr); + + sym_QRcode_free(qr); +#endif + return 0; +} + +int identity_add_recovery_key(JsonVariant **v) { + _cleanup_(erase_and_freep) char *unix_salt = NULL, *password = NULL; + struct crypt_data cd = {}; + char *k; + int r; + + assert(v); + + /* First, let's generate a secret key */ + r = make_recovery_key(&password); + if (r < 0) + return r; + + /* Let's UNIX hash it */ + r = make_salt(&unix_salt); + if (r < 0) + return log_error_errno(r, "Failed to generate salt: %m"); + + errno = 0; + k = crypt_r(password, unix_salt, &cd); + if (!k) + return log_error_errno(errno_or_else(EINVAL), "Failed to UNIX hash secret key: %m"); + + /* Let's now add the "privileged" version of the recovery key */ + r = add_privileged(v, k); + if (r < 0) + return r; + + /* Let's then add the public information about the recovery key */ + r = add_public(v); + if (r < 0) + return r; + + /* Finally, let's add the new key to the secret part, too */ + r = add_secret(v, password); + if (r < 0) + return r; + + /* We output the key itself with a trailing newline to stdout and the decoration around it to stderr + * instead. */ + + fflush(stdout); + fprintf(stderr, + "A secret recovery key has been generated for this account:\n\n" + " %s%s%s", + emoji_enabled() ? special_glyph(SPECIAL_GLYPH_LOCK_AND_KEY) : "", + emoji_enabled() ? " " : "", + ansi_highlight()); + fflush(stderr); + + fputs(password, stdout); + fflush(stdout); + + fputs(ansi_normal(), stderr); + fflush(stderr); + + fputc('\n', stdout); + fflush(stdout); + + fputs("\nPlease save this secret recovery key at a secure location. It may be used to\n" + "regain access to the account if the other configured access credentials have\n" + "been lost or forgotten. The recovery key may be entered in place of a password\n" + "whenever authentication is requested.\n", stderr); + fflush(stderr); + + print_qr_code(password); + + return 0; +} diff --git a/src/home/homectl-recovery-key.h b/src/home/homectl-recovery-key.h new file mode 100644 index 0000000000..489d35fa5b --- /dev/null +++ b/src/home/homectl-recovery-key.h @@ -0,0 +1,6 @@ +/* SPDX-License-Identifier: LGPL-2.1+ */ +#pragma once + +#include "json.h" + +int identity_add_recovery_key(JsonVariant **v); diff --git a/src/home/homectl.c b/src/home/homectl.c index 9e80a1d60f..b83fa837ad 100644 --- a/src/home/homectl.c +++ b/src/home/homectl.c @@ -17,6 +17,7 @@ #include "home-util.h" #include "homectl-fido2.h" #include "homectl-pkcs11.h" +#include "homectl-recovery-key.h" #include "locale-util.h" #include "main-func.h" #include "memory-util.h" @@ -53,6 +54,7 @@ static uint64_t arg_disk_size = UINT64_MAX; static uint64_t arg_disk_size_relative = UINT64_MAX; static char **arg_pkcs11_token_uri = NULL; static char **arg_fido2_device = NULL; +static bool arg_recovery_key = false; static bool arg_json = false; static JsonFormatFlags arg_json_format_flags = 0; static bool arg_and_resize = false; @@ -938,6 +940,12 @@ static int acquire_new_home_record(UserRecord **ret) { return r; } + if (arg_recovery_key) { + r = identity_add_recovery_key(&v); + if (r < 0) + return r; + } + r = update_last_change(&v, true, false); if (r < 0) return r; @@ -960,7 +968,8 @@ static int acquire_new_home_record(UserRecord **ret) { static int acquire_new_password( const char *user_name, UserRecord *hr, - bool suggest) { + bool suggest, + char **ret) { unsigned i = 5; char *e; @@ -971,9 +980,17 @@ static int acquire_new_password( e = getenv("NEWPASSWORD"); if (e) { + _cleanup_(erase_and_freep) char *copy = NULL; + /* As above, this is not for use, just for testing */ - r = user_record_set_password(hr, STRV_MAKE(e), /* prepend = */ false); + if (ret) { + copy = strdup(e); + if (!copy) + return log_oom(); + } + + r = user_record_set_password(hr, STRV_MAKE(e), /* prepend = */ true); if (r < 0) return log_error_errno(r, "Failed to store password: %m"); @@ -982,6 +999,9 @@ static int acquire_new_password( if (unsetenv("NEWPASSWORD") < 0) return log_error_errno(errno, "Failed to unset $NEWPASSWORD: %m"); + if (ret) + *ret = TAKE_PTR(copy); + return 0; } @@ -1011,10 +1031,21 @@ static int acquire_new_password( return log_error_errno(r, "Failed to acquire password: %m"); if (strv_equal(first, second)) { - r = user_record_set_password(hr, first, /* prepend = */ false); + _cleanup_(erase_and_freep) char *copy = NULL; + + if (ret) { + copy = strdup(first[0]); + if (!copy) + return log_oom(); + } + + r = user_record_set_password(hr, first, /* prepend = */ true); if (r < 0) return log_error_errno(r, "Failed to store password: %m"); + if (ret) + *ret = TAKE_PTR(copy); + return 0; } @@ -1025,7 +1056,6 @@ static int acquire_new_password( static int create_home(int argc, char *argv[], void *userdata) { _cleanup_(sd_bus_flush_close_unrefp) sd_bus *bus = NULL; _cleanup_(user_record_unrefp) UserRecord *hr = NULL; - _cleanup_strv_free_ char **original_hashed_passwords = NULL; int r; r = acquire_bus(&bus); @@ -1067,27 +1097,24 @@ static int create_home(int argc, char *argv[], void *userdata) { if (r < 0) return r; - /* Remember the original hashed passwords before we add our own, so that we can return to them later, - * should the entered password turn out not to be acceptable. */ - original_hashed_passwords = strv_copy(hr->hashed_password); - if (!original_hashed_passwords) - return log_oom(); - - /* If the JSON record carries no plain text password, then let's query it manually. */ - if (!hr->password) { + /* If the JSON record carries no plain text password (besides the recovery key), then let's query it + * manually. */ + if (strv_length(hr->password) <= arg_recovery_key) { if (strv_isempty(hr->hashed_password)) { + _cleanup_(erase_and_freep) char *new_password = NULL; + /* No regular (i.e. non-PKCS#11) hashed passwords set in the record, let's fix that. */ - r = acquire_new_password(hr->user_name, hr, /* suggest = */ true); + r = acquire_new_password(hr->user_name, hr, /* suggest = */ true, &new_password); if (r < 0) return r; - r = user_record_make_hashed_password(hr, hr->password, /* extend = */ true); + r = user_record_make_hashed_password(hr, STRV_MAKE(new_password), /* extend = */ false); if (r < 0) return log_error_errno(r, "Failed to hash password: %m"); } else { /* There's a hash password set in the record, acquire the unhashed version of it. */ - r = acquire_existing_password(hr->user_name, hr, false); + r = acquire_existing_password(hr->user_name, hr, /* emphasize_current= */ false); if (r < 0) return r; } @@ -1125,18 +1152,16 @@ static int create_home(int argc, char *argv[], void *userdata) { r = sd_bus_call(bus, m, HOME_SLOW_BUS_CALL_TIMEOUT_USEC, &error, NULL); if (r < 0) { if (sd_bus_error_has_name(&error, BUS_ERROR_LOW_PASSWORD_QUALITY)) { + _cleanup_(erase_and_freep) char *new_password = NULL; + log_error_errno(r, "%s", bus_error_message(&error, r)); log_info("(Use --enforce-password-policy=no to turn off password quality checks for this account.)"); - r = user_record_set_hashed_password(hr, original_hashed_passwords); + r = acquire_new_password(hr->user_name, hr, /* suggest = */ false, &new_password); if (r < 0) return r; - r = acquire_new_password(hr->user_name, hr, /* suggest = */ false); - if (r < 0) - return r; - - r = user_record_make_hashed_password(hr, hr->password, /* extend = */ true); + r = user_record_make_hashed_password(hr, STRV_MAKE(new_password), /* extend = */ false); if (r < 0) return log_error_errno(r, "Failed to hash passwords: %m"); } else { @@ -1489,7 +1514,7 @@ static int passwd_home(int argc, char *argv[], void *userdata) { if (!new_secret) return log_oom(); - r = acquire_new_password(username, new_secret, /* suggest = */ true); + r = acquire_new_password(username, new_secret, /* suggest = */ true, NULL); if (r < 0) return r; @@ -1519,7 +1544,7 @@ static int passwd_home(int argc, char *argv[], void *userdata) { log_error_errno(r, "%s", bus_error_message(&error, r)); - r = acquire_new_password(username, new_secret, /* suggest = */ false); + r = acquire_new_password(username, new_secret, /* suggest = */ false, NULL); } else if (sd_bus_error_has_name(&error, BUS_ERROR_BAD_PASSWORD_AND_NO_TOKEN)) @@ -1914,6 +1939,7 @@ static int help(int argc, char *argv[], void *userdata) { " private key and matching X.509 certificate\n" " --fido2-device=PATH Path to FIDO2 hidraw device with hmac-secret\n" " extension\n" + " --recovery-key=BOOL Add a recovery key\n" "\n%4$sAccount Management User Record Properties:%5$s\n" " --locked=BOOL Set locked account state\n" " --not-before=TIMESTAMP Do not allow logins before\n" @@ -2061,6 +2087,7 @@ static int parse_argv(int argc, char *argv[]) { ARG_AUTO_LOGIN, ARG_PKCS11_TOKEN_URI, ARG_FIDO2_DEVICE, + ARG_RECOVERY_KEY, ARG_AND_RESIZE, ARG_AND_CHANGE_PASSWORD, }; @@ -2139,6 +2166,7 @@ static int parse_argv(int argc, char *argv[]) { { "export-format", required_argument, NULL, ARG_EXPORT_FORMAT }, { "pkcs11-token-uri", required_argument, NULL, ARG_PKCS11_TOKEN_URI }, { "fido2-device", required_argument, NULL, ARG_FIDO2_DEVICE }, + { "recovery-key", required_argument, NULL, ARG_RECOVERY_KEY }, { "and-resize", required_argument, NULL, ARG_AND_RESIZE }, { "and-change-password", required_argument, NULL, ARG_AND_CHANGE_PASSWORD }, {} @@ -3169,6 +3197,24 @@ static int parse_argv(int argc, char *argv[]) { break; } + case ARG_RECOVERY_KEY: { + const char *p; + + r = parse_boolean(optarg); + if (r < 0) + return log_error_errno(r, "Failed to parse --recovery-key= argument: %s", optarg); + + arg_recovery_key = r; + + FOREACH_STRING(p, "recoveryKey", "recoveryKeyType") { + r = drop_from_identity(p); + if (r < 0) + return r; + } + + break; + } + case 'j': arg_json = true; arg_json_format_flags = JSON_FORMAT_PRETTY_AUTO|JSON_FORMAT_COLOR_AUTO; diff --git a/src/home/homed-home-bus.c b/src/home/homed-home-bus.c index 6be361a5aa..015eefb718 100644 --- a/src/home/homed-home-bus.c +++ b/src/home/homed-home-bus.c @@ -669,6 +669,7 @@ int bus_home_method_ref( return sd_bus_error_setf(error, BUS_ERROR_HOME_ABSENT, "Home %s is currently missing or not plugged in.", h->user_name); case HOME_UNFIXATED: case HOME_INACTIVE: + case HOME_DIRTY: return sd_bus_error_setf(error, BUS_ERROR_HOME_NOT_ACTIVE, "Home %s not active.", h->user_name); case HOME_LOCKED: return sd_bus_error_setf(error, BUS_ERROR_HOME_LOCKED, "Home %s is currently locked.", h->user_name); diff --git a/src/home/homed-home.c b/src/home/homed-home.c index 45c2152531..6a6cde7c02 100644 --- a/src/home/homed-home.c +++ b/src/home/homed-home.c @@ -454,6 +454,8 @@ static int convert_worker_errno(Home *h, int e, sd_bus_error *error) { return sd_bus_error_setf(error, BUS_ERROR_BAD_PASSWORD, "Password for home %s is incorrect or not sufficient for authentication.", h->user_name); case -EBADSLT: return sd_bus_error_setf(error, BUS_ERROR_BAD_PASSWORD_AND_NO_TOKEN, "Password for home %s is incorrect or not sufficient, and configured security token not found either.", h->user_name); + case -EREMOTEIO: + return sd_bus_error_setf(error, BUS_ERROR_BAD_RECOVERY_KEY, "Recovery key for home %s is incorrect or not sufficient for authentication.", h->user_name); case -ENOANO: return sd_bus_error_setf(error, BUS_ERROR_TOKEN_PIN_NEEDED, "PIN for security token required."); case -ERFKILL: @@ -1132,6 +1134,7 @@ int home_fixate(Home *h, UserRecord *secret, sd_bus_error *error) { case HOME_ABSENT: return sd_bus_error_setf(error, BUS_ERROR_HOME_ABSENT, "Home %s is currently missing or not plugged in.", h->user_name); case HOME_INACTIVE: + case HOME_DIRTY: case HOME_ACTIVE: case HOME_LOCKED: return sd_bus_error_setf(error, BUS_ERROR_HOME_ALREADY_FIXATED, "Home %s is already fixated.", h->user_name); @@ -1177,6 +1180,7 @@ int home_activate(Home *h, UserRecord *secret, sd_bus_error *error) { case HOME_LOCKED: return sd_bus_error_setf(error, BUS_ERROR_HOME_LOCKED, "Home %s is currently locked.", h->user_name); case HOME_INACTIVE: + case HOME_DIRTY: break; default: return sd_bus_error_setf(error, BUS_ERROR_HOME_BUSY, "An operation on home %s is currently being executed.", h->user_name); @@ -1217,6 +1221,7 @@ int home_authenticate(Home *h, UserRecord *secret, sd_bus_error *error) { return sd_bus_error_setf(error, BUS_ERROR_HOME_LOCKED, "Home %s is currently locked.", h->user_name); case HOME_UNFIXATED: case HOME_INACTIVE: + case HOME_DIRTY: case HOME_ACTIVE: break; default: @@ -1250,6 +1255,7 @@ int home_deactivate(Home *h, bool force, sd_bus_error *error) { case HOME_UNFIXATED: case HOME_ABSENT: case HOME_INACTIVE: + case HOME_DIRTY: return sd_bus_error_setf(error, BUS_ERROR_HOME_NOT_ACTIVE, "Home %s not active.", h->user_name); case HOME_LOCKED: return sd_bus_error_setf(error, BUS_ERROR_HOME_LOCKED, "Home %s is currently locked.", h->user_name); @@ -1278,6 +1284,7 @@ int home_create(Home *h, UserRecord *secret, sd_bus_error *error) { _fallthrough_; case HOME_UNFIXATED: + case HOME_DIRTY: return sd_bus_error_setf(error, BUS_ERROR_HOME_EXISTS, "Home of user %s already exists.", h->user_name); case HOME_ABSENT: break; @@ -1317,6 +1324,7 @@ int home_remove(Home *h, sd_bus_error *error) { return sd_bus_error_setf(error, BUS_ERROR_HOME_LOCKED, "Home %s is currently locked.", h->user_name); case HOME_UNFIXATED: case HOME_INACTIVE: + case HOME_DIRTY: break; case HOME_ACTIVE: default: @@ -1455,6 +1463,7 @@ int home_update(Home *h, UserRecord *hr, sd_bus_error *error) { case HOME_LOCKED: return sd_bus_error_setf(error, BUS_ERROR_HOME_LOCKED, "Home %s is currently locked.", h->user_name); case HOME_INACTIVE: + case HOME_DIRTY: case HOME_ACTIVE: break; default: @@ -1489,6 +1498,7 @@ int home_resize(Home *h, uint64_t disk_size, UserRecord *secret, sd_bus_error *e case HOME_LOCKED: return sd_bus_error_setf(error, BUS_ERROR_HOME_LOCKED, "Home %s is currently locked.", h->user_name); case HOME_INACTIVE: + case HOME_DIRTY: case HOME_ACTIVE: break; default: @@ -1584,6 +1594,7 @@ int home_passwd(Home *h, case HOME_LOCKED: return sd_bus_error_setf(error, BUS_ERROR_HOME_LOCKED, "Home %s is currently locked.", h->user_name); case HOME_INACTIVE: + case HOME_DIRTY: case HOME_ACTIVE: break; default: @@ -1666,6 +1677,7 @@ int home_unregister(Home *h, sd_bus_error *error) { return sd_bus_error_setf(error, BUS_ERROR_HOME_LOCKED, "Home %s is currently locked.", h->user_name); case HOME_ABSENT: case HOME_INACTIVE: + case HOME_DIRTY: break; case HOME_ACTIVE: default: @@ -1690,6 +1702,7 @@ int home_lock(Home *h, sd_bus_error *error) { case HOME_UNFIXATED: case HOME_ABSENT: case HOME_INACTIVE: + case HOME_DIRTY: return sd_bus_error_setf(error, BUS_ERROR_HOME_NOT_ACTIVE, "Home %s is not active.", h->user_name); case HOME_LOCKED: return sd_bus_error_setf(error, BUS_ERROR_HOME_LOCKED, "Home %s is already locked.", h->user_name); @@ -1734,6 +1747,7 @@ int home_unlock(Home *h, UserRecord *secret, sd_bus_error *error) { case HOME_ABSENT: case HOME_INACTIVE: case HOME_ACTIVE: + case HOME_DIRTY: return sd_bus_error_setf(error, BUS_ERROR_HOME_NOT_LOCKED, "Home %s is not locked.", h->user_name); case HOME_LOCKED: break; @@ -1745,6 +1759,7 @@ int home_unlock(Home *h, UserRecord *secret, sd_bus_error *error) { } HomeState home_get_state(Home *h) { + int r; assert(h); /* When the state field is initialized, it counts. */ @@ -1757,8 +1772,11 @@ HomeState home_get_state(Home *h) { return HOME_ACTIVE; /* And if we see the image being gone, we report this as absent */ - if (user_record_test_image_path(h->record) == USER_TEST_ABSENT) + r = user_record_test_image_path(h->record); + if (r == USER_TEST_ABSENT) return HOME_ABSENT; + if (r == USER_TEST_DIRTY) + return HOME_DIRTY; /* And for all other cases we return "inactive". */ return HOME_INACTIVE; @@ -2324,6 +2342,7 @@ static int home_dispatch_acquire(Home *h, Operation *o) { break; case HOME_INACTIVE: + case HOME_DIRTY: for_state = HOME_ACTIVATING_FOR_ACQUIRE; call = home_activate_internal; break; @@ -2377,6 +2396,7 @@ static int home_dispatch_release(Home *h, Operation *o) { case HOME_UNFIXATED: case HOME_ABSENT: case HOME_INACTIVE: + case HOME_DIRTY: r = 1; /* done */ break; @@ -2418,6 +2438,7 @@ static int home_dispatch_lock_all(Home *h, Operation *o) { case HOME_UNFIXATED: case HOME_ABSENT: case HOME_INACTIVE: + case HOME_DIRTY: log_info("Home %s is not active, no locking necessary.", h->user_name); r = 1; /* done */ break; @@ -2464,6 +2485,7 @@ static int home_dispatch_pipe_eof(Home *h, Operation *o) { case HOME_UNFIXATED: case HOME_ABSENT: case HOME_INACTIVE: + case HOME_DIRTY: log_info("Home %s already deactivated, no automatic deactivation needed.", h->user_name); break; @@ -2503,6 +2525,7 @@ static int home_dispatch_deactivate_force(Home *h, Operation *o) { case HOME_UNFIXATED: case HOME_ABSENT: case HOME_INACTIVE: + case HOME_DIRTY: log_debug("Home %s already deactivated, no forced deactivation due to unplug needed.", h->user_name); break; @@ -2714,6 +2737,7 @@ static const char* const home_state_table[_HOME_STATE_MAX] = { [HOME_UNFIXATED] = "unfixated", [HOME_ABSENT] = "absent", [HOME_INACTIVE] = "inactive", + [HOME_DIRTY] = "dirty", [HOME_FIXATING] = "fixating", [HOME_FIXATING_FOR_ACTIVATION] = "fixating-for-activation", [HOME_FIXATING_FOR_ACQUIRE] = "fixating-for-acquire", diff --git a/src/home/homed-home.h b/src/home/homed-home.h index ec7966fb14..b638c6cbb1 100644 --- a/src/home/homed-home.h +++ b/src/home/homed-home.h @@ -13,6 +13,7 @@ typedef enum HomeState { HOME_UNFIXATED, /* home exists, but local record does not */ HOME_ABSENT, /* local record exists, but home does not */ HOME_INACTIVE, /* record and home exist, but is not logged in */ + HOME_DIRTY, /* like HOME_INACTIVE, but the home directory wasn't cleanly deactivated */ HOME_FIXATING, /* generating local record from home */ HOME_FIXATING_FOR_ACTIVATION, /* fixating in order to activate soon */ HOME_FIXATING_FOR_ACQUIRE, /* fixating because Acquire() was called */ diff --git a/src/home/homework-luks.c b/src/home/homework-luks.c index 1fe0465cfb..b263d75827 100644 --- a/src/home/homework-luks.c +++ b/src/home/homework-luks.c @@ -5,6 +5,7 @@ #include #include #include +#include #include "blkid-util.h" #include "blockdev-util.h" @@ -41,6 +42,53 @@ * strictly round disk sizes down to the next 1K boundary.*/ #define DISK_SIZE_ROUND_DOWN(x) ((x) & ~UINT64_C(1023)) +int run_mark_dirty(int fd, bool b) { + char x = '1'; + int r, ret; + + /* Sets or removes the 'user.home-dirty' xattr on the specified file. We use this to detect when a + * home directory was not properly unmounted. */ + + assert(fd >= 0); + + r = fd_verify_regular(fd); + if (r < 0) + return r; + + if (b) { + ret = fsetxattr(fd, "user.home-dirty", &x, 1, XATTR_CREATE); + if (ret < 0 && errno != EEXIST) + return log_debug_errno(errno, "Could not mark home directory as dirty: %m"); + + } else { + r = fsync_full(fd); + if (r < 0) + return log_debug_errno(r, "Failed to synchronize image before marking it clean: %m"); + + ret = fremovexattr(fd, "user.home-dirty"); + if (ret < 0 && errno != ENODATA) + return log_debug_errno(errno, "Could not mark home directory as clean: %m"); + } + + r = fsync_full(fd); + if (r < 0) + return log_debug_errno(r, "Failed to synchronize dirty flag to disk: %m"); + + return ret >= 0; +} + +int run_mark_dirty_by_path(const char *path, bool b) { + _cleanup_close_ int fd = -1; + + assert(path); + + fd = open(path, O_RDWR|O_CLOEXEC|O_NOCTTY); + if (fd < 0) + return log_debug_errno(errno, "Failed to open %s to mark dirty or clean: %m", path); + + return run_mark_dirty(fd, b); +} + static int probe_file_system_by_fd( int fd, char **ret_fstype, @@ -998,9 +1046,10 @@ int home_prepare_luks( _cleanup_(loop_device_unrefp) LoopDevice *loop = NULL; _cleanup_(crypt_freep) struct crypt_device *cd = NULL; _cleanup_(erase_and_freep) void *volume_key = NULL; + _cleanup_close_ int root_fd = -1, image_fd = -1; bool dm_activated = false, mounted = false; - _cleanup_close_ int root_fd = -1; size_t volume_key_size = 0; + bool marked_dirty = false; uint64_t offset, size; int r; @@ -1094,7 +1143,6 @@ int home_prepare_luks( } } else { _cleanup_free_ char *fstype = NULL, *subdir = NULL; - _cleanup_close_ int fd = -1; const char *ip; struct stat st; @@ -1104,28 +1152,32 @@ int home_prepare_luks( if (!subdir) return log_oom(); - fd = open(ip, O_RDWR|O_CLOEXEC|O_NOCTTY|O_NONBLOCK); - if (fd < 0) + image_fd = open(ip, O_RDWR|O_CLOEXEC|O_NOCTTY|O_NONBLOCK); + if (image_fd < 0) return log_error_errno(errno, "Failed to open image file %s: %m", ip); - if (fstat(fd, &st) < 0) + if (fstat(image_fd, &st) < 0) return log_error_errno(errno, "Failed to fstat() image file: %m"); if (!S_ISREG(st.st_mode) && !S_ISBLK(st.st_mode)) return log_error_errno( S_ISDIR(st.st_mode) ? SYNTHETIC_ERRNO(EISDIR) : SYNTHETIC_ERRNO(EBADFD), "Image file %s is not a regular file or block device: %m", ip); - r = luks_validate(fd, user_record_user_name_and_realm(h), h->partition_uuid, &found_partition_uuid, &offset, &size); + r = luks_validate(image_fd, user_record_user_name_and_realm(h), h->partition_uuid, &found_partition_uuid, &offset, &size); if (r < 0) return log_error_errno(r, "Failed to validate disk label: %m"); + /* Everything before this point left the image untouched. We are now starting to make + * changes, hence mark the image dirty */ + marked_dirty = run_mark_dirty(image_fd, true) > 0; + if (!user_record_luks_discard(h)) { - r = run_fallocate(fd, &st); + r = run_fallocate(image_fd, &st); if (r < 0) return r; } - r = loop_device_make(fd, O_RDWR, offset, size, 0, &loop); + r = loop_device_make(image_fd, O_RDWR, offset, size, 0, &loop); if (r == -ENOENT) { log_error_errno(r, "Loopback block device support is not available on this system."); return -ENOLINK; /* make recognizable */ @@ -1180,8 +1232,9 @@ int home_prepare_luks( if (user_record_luks_discard(h)) (void) run_fitrim(root_fd); - setup->image_fd = TAKE_FD(fd); + setup->image_fd = TAKE_FD(image_fd); setup->do_offline_fallocate = !(setup->do_offline_fitrim = user_record_luks_offline_discard(h)); + setup->do_mark_clean = marked_dirty; } setup->loop = TAKE_PTR(loop); @@ -1210,6 +1263,9 @@ fail: if (dm_activated) (void) crypt_deactivate(cd, setup->dm_name); + if (image_fd >= 0 && marked_dirty) + (void) run_mark_dirty(image_fd, false); + return r; } @@ -1304,6 +1360,7 @@ int home_activate_luks( setup.undo_dm = false; setup.do_offline_fallocate = false; + setup.do_mark_clean = false; log_info("Everything completed."); @@ -1357,6 +1414,7 @@ int home_deactivate_luks(UserRecord *h) { else (void) run_fallocate_by_path(user_record_image_path(h)); + run_mark_dirty_by_path(user_record_image_path(h), false); return we_detached; } diff --git a/src/home/homework-luks.h b/src/home/homework-luks.h index b51f1ad7a0..4d3e085ff0 100644 --- a/src/home/homework-luks.h +++ b/src/home/homework-luks.h @@ -42,3 +42,5 @@ int run_fitrim(int root_fd); int run_fitrim_by_path(const char *root_path); int run_fallocate(int backing_fd, const struct stat *st); int run_fallocate_by_path(const char *backing_path); +int run_mark_dirty(int fd, bool b); +int run_mark_dirty_by_path(const char *path, bool b); diff --git a/src/home/homework.c b/src/home/homework.c index 83bd875d2d..594c4a05bb 100644 --- a/src/home/homework.c +++ b/src/home/homework.c @@ -20,6 +20,7 @@ #include "main-func.h" #include "memory-util.h" #include "missing_magic.h" +#include "modhex.h" #include "mount-util.h" #include "path-util.h" #include "rm-rf.h" @@ -46,7 +47,7 @@ int user_record_authenticate( PasswordCache *cache, bool strict_verify) { - bool need_password = false, need_token = false, need_pin = false, need_protected_authentication_path_permitted = false, need_user_presence_permitted = false, + bool need_password = false, need_recovery_key = false, need_token = false, need_pin = false, need_protected_authentication_path_permitted = false, need_user_presence_permitted = false, pin_locked = false, pin_incorrect = false, pin_incorrect_few_tries_left = false, pin_incorrect_one_try_left = false, token_action_timeout = false; int r; @@ -65,11 +66,10 @@ int user_record_authenticate( * PKCS#11/FIDO2 dance for the relevant token again and again. */ /* First, let's see if the supplied plain-text passwords work? */ - r = user_record_test_secret(h, secret); - if (r == -ENOKEY) { - log_info_errno(r, "None of the supplied plaintext passwords unlocks the user record's hashed passwords."); + r = user_record_test_password(h, secret); + if (r == -ENOKEY) need_password = true; - } else if (r == -ENXIO) + else if (r == -ENXIO) log_debug_errno(r, "User record has no hashed passwords, plaintext passwords not tested."); else if (r < 0) return log_error_errno(r, "Failed to validate password of record: %m"); @@ -78,6 +78,26 @@ int user_record_authenticate( return 1; } + /* Similar, but test against the recovery keys */ + r = user_record_test_recovery_key(h, secret); + if (r == -ENOKEY) + need_recovery_key = true; + else if (r == -ENXIO) + log_debug_errno(r, "User record has no recovery keys, plaintext passwords not tested against it."); + else if (r < 0) + return log_error_errno(r, "Failed to validate the recovery key of the record: %m"); + else { + log_info("Provided password is a recovery key that unlocks the user record."); + return 1; + } + + if (need_password && need_recovery_key) + log_info("None of the supplied plaintext passwords unlock the user record's hashed passwords or recovery keys."); + else if (need_password) + log_info("None of the supplied plaintext passwords unlock the user record's hashed passwords."); + else + log_info("None of the supplied plaintext passwords unlock the user record's hashed recovery keys."); + /* Second, test cached PKCS#11 passwords */ for (size_t n = 0; n < h->n_pkcs11_encrypted_key; n++) { char **pp; @@ -235,19 +255,21 @@ int user_record_authenticate( return -EBADSLT; if (need_password) return -ENOKEY; + if (need_recovery_key) + return -EREMOTEIO; - /* Hmm, this means neither PCKS#11/FIDO2 nor classic hashed passwords were supplied, we cannot - * authenticate this reasonably */ + /* Hmm, this means neither PCKS#11/FIDO2 nor classic hashed passwords or recovery keys were supplied, + * we cannot authenticate this reasonably */ if (strict_verify) return log_debug_errno(SYNTHETIC_ERRNO(EKEYREVOKED), - "No hashed passwords and no PKCS#11/FIDO2 tokens defined, cannot authenticate user record, refusing."); + "No hashed passwords, no recovery keys and no PKCS#11/FIDO2 tokens defined, cannot authenticate user record, refusing."); /* If strict verification is off this means we are possibly in the case where we encountered an * unfixated record, i.e. a synthetic one that accordingly lacks any authentication data. In this * case, allow the authentication to pass for now, so that the second (or third) authentication level * (the ones of the user record in the LUKS header or inside the home directory) will then catch * invalid passwords. The second/third authentication always runs in strict verification mode. */ - log_debug("No hashed passwords and no PKCS#11 tokens defined in record, cannot authenticate user record. " + log_debug("No hashed passwords, not recovery keys and no PKCS#11 tokens defined in record, cannot authenticate user record. " "Deferring to embedded user record."); return 0; } @@ -286,6 +308,12 @@ int home_setup_undo(HomeSetup *setup) { r = q; } + if (setup->do_mark_clean) { + q = run_mark_dirty(setup->image_fd, false); + if (q < 0) + r = q; + } + setup->image_fd = safe_close(setup->image_fd); } @@ -293,6 +321,7 @@ int home_setup_undo(HomeSetup *setup) { setup->undo_dm = false; setup->do_offline_fitrim = false; setup->do_offline_fallocate = false; + setup->do_mark_clean = false; setup->dm_name = mfree(setup->dm_name); setup->dm_node = mfree(setup->dm_node); @@ -896,7 +925,7 @@ static int user_record_compile_effective_passwords( STRV_FOREACH(j, h->password) { r = test_password_one(*i, *j); if (r < 0) - return log_error_errno(r, "Failed to test plain text password: %m"); + return log_error_errno(r, "Failed to test plaintext password: %m"); if (r > 0) { if (ret_effective_passwords) { r = strv_extend(&effective, *j); @@ -914,6 +943,48 @@ static int user_record_compile_effective_passwords( return log_error_errno(SYNTHETIC_ERRNO(ENOKEY), "Missing plaintext password for defined hashed password"); } + for (n = 0; n < h->n_recovery_key; n++) { + bool found = false; + char **j; + + log_debug("Looking for plaintext recovery key for: %s", h->recovery_key[n].hashed_password); + + STRV_FOREACH(j, h->password) { + _cleanup_(erase_and_freep) char *mangled = NULL; + const char *p; + + if (streq(h->recovery_key[n].type, "modhex64")) { + + r = normalize_recovery_key(*j, &mangled); + if (r == -EINVAL) /* Not properly formatted, probably a regular password. */ + continue; + if (r < 0) + return log_error_errno(r, "Failed to normalize recovery key: %m"); + + p = mangled; + } else + p = *j; + + r = test_password_one(h->recovery_key[n].hashed_password, p); + if (r < 0) + return log_error_errno(r, "Failed to test plaintext recovery key: %m"); + if (r > 0) { + if (ret_effective_passwords) { + r = strv_extend(&effective, p); + if (r < 0) + return log_oom(); + } + + log_debug("Found plaintext recovery key."); + found = true; + break; + } + } + + if (!found) + return log_error_errno(SYNTHETIC_ERRNO(EREMOTEIO), "Missing plaintext recovery key for defined recovery key"); + } + for (n = 0; n < h->n_pkcs11_encrypted_key; n++) { #if HAVE_P11KIT _cleanup_(pkcs11_callback_data_release) struct pkcs11_callback_data data = { @@ -1602,6 +1673,7 @@ static int run(int argc, char *argv[]) { * ENOTTY → operation not support on this storage * ESOCKTNOSUPPORT → operation not support on this file system * ENOKEY → password incorrect (or not sufficient, or not supplied) + * EREMOTEIO → recovery key incorrect (or not sufficeint, or not supplied — only if no passwords defined) * EBADSLT → similar, but PKCS#11 device is defined and might be able to provide password, if it was plugged in which it is not * ENOANO → suitable PKCS#11/FIDO2 device found, but PIN is missing to unlock it * ERFKILL → suitable PKCS#11 device found, but OK to ask for on-device interactive authentication not given @@ -1641,7 +1713,7 @@ static int run(int argc, char *argv[]) { r = home_unlock(home); else return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Unknown verb '%s'.", argv[1]); - if (r == -ENOKEY && !strv_isempty(home->password)) { /* There were passwords specified but they were incorrect */ + if (IN_SET(r, -ENOKEY, -EREMOTEIO) && !strv_isempty(home->password) ) { /* There were passwords specified but they were incorrect */ usec_t end, n, d; /* Make sure bad password replies always take at least 3s, and if longer multiples of 3s, so diff --git a/src/home/homework.h b/src/home/homework.h index ce8f2a461f..c9b0d3b432 100644 --- a/src/home/homework.h +++ b/src/home/homework.h @@ -31,6 +31,7 @@ typedef struct HomeSetup { bool undo_mount; bool do_offline_fitrim; bool do_offline_fallocate; + bool do_mark_clean; uint64_t partition_offset; uint64_t partition_size; diff --git a/src/home/meson.build b/src/home/meson.build index fbf09501c4..69bacacf80 100644 --- a/src/home/meson.build +++ b/src/home/meson.build @@ -7,6 +7,7 @@ systemd_homework_sources = files(''' homework-cifs.h homework-directory.c homework-directory.h + homework-fido2.h homework-fscrypt.c homework-fscrypt.h homework-luks.c @@ -14,11 +15,12 @@ systemd_homework_sources = files(''' homework-mount.c homework-mount.h homework-pkcs11.h - homework-fido2.h homework-quota.c homework-quota.h homework.c homework.h + modhex.c + modhex.h user-record-util.c user-record-util.h '''.split()) @@ -50,6 +52,8 @@ systemd_homed_sources = files(''' homed-varlink.c homed-varlink.h homed.c + modhex.c + modhex.h user-record-pwquality.c user-record-pwquality.h user-record-sign.c @@ -73,7 +77,11 @@ homectl_sources = files(''' homectl-fido2.h homectl-pkcs11.c homectl-pkcs11.h + homectl-recovery-key.c + homectl-recovery-key.h homectl.c + modhex.c + modhex.h user-record-pwquality.c user-record-pwquality.h user-record-util.c @@ -84,6 +92,8 @@ pam_systemd_home_sym = 'src/home/pam_systemd_home.sym' pam_systemd_home_c = files(''' home-util.c home-util.h + modhex.c + modhex.h pam_systemd_home.c user-record-util.c user-record-util.h @@ -100,3 +110,11 @@ if conf.get('ENABLE_HOMED') == 1 install_data('homed.conf', install_dir : pkgsysconfdir) endif + +tests += [ + [['src/home/test-modhex.c', + 'src/home/modhex.c', + 'src/home/modhex.h'], + [], + []], +] diff --git a/src/home/modhex.c b/src/home/modhex.c new file mode 100644 index 0000000000..82a5b20d3f --- /dev/null +++ b/src/home/modhex.c @@ -0,0 +1,74 @@ +/* SPDX-License-Identifier: LGPL-2.1+ */ + +#include + +#include "modhex.h" +#include "macro.h" +#include "memory-util.h" + +const char modhex_alphabet[16] = { + 'c', 'b', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'n', 'r', 't', 'u', 'v' +}; + +int decode_modhex_char(char x) { + + for (size_t i = 0; i < ELEMENTSOF(modhex_alphabet); i++) + /* Check both upper and lowercase */ + if (modhex_alphabet[i] == x || (modhex_alphabet[i] - 32) == x) + return i; + + return -EINVAL; +} + +int normalize_recovery_key(const char *password, char **ret) { + _cleanup_(erase_and_freep) char *mangled = NULL; + size_t l; + + assert(password); + assert(ret); + + l = strlen(password); + if (!IN_SET(l, + MODHEX_RAW_LENGTH*2, /* syntax without dashes */ + MODHEX_FORMATTED_LENGTH-1)) /* syntax with dashes */ + return -EINVAL; + + mangled = new(char, MODHEX_FORMATTED_LENGTH); + if (!mangled) + return -ENOMEM; + + for (size_t i = 0, j = 0; i < MODHEX_RAW_LENGTH; i++) { + size_t k; + int a, b; + + if (l == MODHEX_RAW_LENGTH*2) + /* Syntax without dashes */ + k = i * 2; + else { + /* Syntax with dashes */ + assert(l == MODHEX_FORMATTED_LENGTH-1); + k = i * 2 + i / 4; + + if (i > 0 && i % 4 == 0 && password[k-1] != '-') + return -EINVAL; + } + + a = decode_modhex_char(password[k]); + if (a < 0) + return -EINVAL; + b = decode_modhex_char(password[k+1]); + if (b < 0) + return -EINVAL; + + mangled[j++] = modhex_alphabet[a]; + mangled[j++] = modhex_alphabet[b]; + + if (i % 4 == 3) + mangled[j++] = '-'; + } + + mangled[MODHEX_FORMATTED_LENGTH-1] = 0; + + *ret = TAKE_PTR(mangled); + return 0; +} diff --git a/src/home/modhex.h b/src/home/modhex.h new file mode 100644 index 0000000000..e0067ae44b --- /dev/null +++ b/src/home/modhex.h @@ -0,0 +1,14 @@ +/* SPDX-License-Identifier: LGPL-2.1+ */ +#pragma once + +/* 256 bit keys = 32 bytes */ +#define MODHEX_RAW_LENGTH 32 + +/* Formatted as sequences of 64 modhex characters, with dashes inserted after multiples of 8 chars (incl. trailing NUL) */ +#define MODHEX_FORMATTED_LENGTH (MODHEX_RAW_LENGTH*2/8*9) + +extern const char modhex_alphabet[16]; + +int decode_modhex_char(char x); + +int normalize_recovery_key(const char *password, char **ret); diff --git a/src/home/test-modhex.c b/src/home/test-modhex.c new file mode 100644 index 0000000000..4eebeeaaea --- /dev/null +++ b/src/home/test-modhex.c @@ -0,0 +1,51 @@ +/* SPDX-License-Identifier: LGPL-2.1+ */ + +#include "modhex.h" +#include "alloc-util.h" +#include "string-util.h" + +static void test_normalize_recovery_key(const char *t, const char *expected) { + _cleanup_free_ char *z = NULL; + int r; + + assert(t); + + r = normalize_recovery_key(t, &z); + assert_se(expected ? + (r >= 0 && streq(z, expected)) : + (r == -EINVAL && z == NULL)); +} + +int main(int argc, char *arv[]) { + + test_normalize_recovery_key("iefgcelh-biduvkjv-cjvuncnk-vlfchdid-jhtuhhde-urkllkeg-ilkjgbrt-hjkbgktj", + "iefgcelh-biduvkjv-cjvuncnk-vlfchdid-jhtuhhde-urkllkeg-ilkjgbrt-hjkbgktj"); + + test_normalize_recovery_key("iefgcelhbiduvkjvcjvuncnkvlfchdidjhtuhhdeurkllkegilkjgbrthjkbgktj", + "iefgcelh-biduvkjv-cjvuncnk-vlfchdid-jhtuhhde-urkllkeg-ilkjgbrt-hjkbgktj"); + + test_normalize_recovery_key("IEFGCELH-BIDUVKJV-CJVUNCNK-VLFCHDID-JHTUHHDE-URKLLKEG-ILKJGBRT-HJKBGKTJ", + "iefgcelh-biduvkjv-cjvuncnk-vlfchdid-jhtuhhde-urkllkeg-ilkjgbrt-hjkbgktj"); + + test_normalize_recovery_key("IEFGCELHBIDUVKJVCJVUNCNKVLFCHDIDJHTUHHDEURKLLKEGILKJGBRTHJKBGKTJ", + "iefgcelh-biduvkjv-cjvuncnk-vlfchdid-jhtuhhde-urkllkeg-ilkjgbrt-hjkbgktj"); + + test_normalize_recovery_key("Iefgcelh-Biduvkjv-Cjvuncnk-Vlfchdid-Jhtuhhde-Urkllkeg-Ilkjgbrt-Hjkbgktj", + "iefgcelh-biduvkjv-cjvuncnk-vlfchdid-jhtuhhde-urkllkeg-ilkjgbrt-hjkbgktj"); + + test_normalize_recovery_key("Iefgcelhbiduvkjvcjvuncnkvlfchdidjhtuhhdeurkllkegilkjgbrthjkbgktj", + "iefgcelh-biduvkjv-cjvuncnk-vlfchdid-jhtuhhde-urkllkeg-ilkjgbrt-hjkbgktj"); + + test_normalize_recovery_key("iefgcelh-biduvkjv-cjvuncnk-vlfchdid-jhtuhhde-urkllkeg-ilkjgbrt-hjkbgkt", NULL); + test_normalize_recovery_key("iefgcelhbiduvkjvcjvuncnkvlfchdidjhtuhhdeurkllkegilkjgbrthjkbgkt", NULL); + test_normalize_recovery_key("IEFGCELHBIDUVKJVCJVUNCNKVLFCHDIDJHTUHHDEURKLLKEGILKJGBRTHJKBGKT", NULL); + + test_normalize_recovery_key("xefgcelh-biduvkjv-cjvuncnk-vlfchdid-jhtuhhde-urkllkeg-ilkjgbrt-hjkbgktj", NULL); + test_normalize_recovery_key("Xefgcelh-biduvkjv-cjvuncnk-vlfchdid-jhtuhhde-urkllkeg-ilkjgbrt-hjkbgktj", NULL); + test_normalize_recovery_key("iefgcelh+biduvkjv-cjvuncnk-vlfchdid-jhtuhhde-urkllkeg-ilkjgbrt-hjkbgktj", NULL); + test_normalize_recovery_key("iefgcelhebiduvkjv-cjvuncnk-vlfchdid-jhtuhhde-urkllkeg-ilkjgbrt-hjkbgktj", NULL); + + test_normalize_recovery_key("", NULL); + + return 0; +} diff --git a/src/home/user-record-util.c b/src/home/user-record-util.c index 516ffaa8a6..0bbe44ce26 100644 --- a/src/home/user-record-util.c +++ b/src/home/user-record-util.c @@ -1,9 +1,13 @@ /* SPDX-License-Identifier: LGPL-2.1+ */ +#include + #include "errno-util.h" #include "home-util.h" #include "id128-util.h" #include "libcrypt-util.h" +#include "memory-util.h" +#include "modhex.h" #include "mountpoint-util.h" #include "path-util.h" #include "stat-util.h" @@ -495,8 +499,21 @@ int user_record_test_image_path(UserRecord *h) { switch (user_record_storage(h)) { case USER_LUKS: - if (S_ISREG(st.st_mode)) + if (S_ISREG(st.st_mode)) { + ssize_t n; + char x[2]; + + n = getxattr(ip, "user.home-dirty", x, sizeof(x)); + if (n < 0) { + if (errno != ENODATA) + log_debug_errno(errno, "Unable to read dirty xattr off image file, ignoring: %m"); + + } else if (n == 1 && x[0] == '1') + return USER_TEST_DIRTY; + return USER_TEST_EXISTS; + } + if (S_ISBLK(st.st_mode)) { /* For block devices we can't really be sure if the device referenced actually is the * fs we look for or some other file system (think: what does /dev/sdb1 refer @@ -549,7 +566,7 @@ int user_record_test_image_path_and_warn(UserRecord *h) { return r; } -int user_record_test_secret(UserRecord *h, UserRecord *secret) { +int user_record_test_password(UserRecord *h, UserRecord *secret) { char **i; int r; @@ -571,6 +588,48 @@ int user_record_test_secret(UserRecord *h, UserRecord *secret) { return -ENOKEY; } +int user_record_test_recovery_key(UserRecord *h, UserRecord *secret) { + char **i; + int r; + + assert(h); + + /* Checks whether any of the specified passwords matches any of the hashed recovery keys of the entry */ + + if (h->n_recovery_key == 0) + return -ENXIO; + + STRV_FOREACH(i, secret->password) { + for (size_t j = 0; j < h->n_recovery_key; j++) { + _cleanup_(erase_and_freep) char *mangled = NULL; + const char *p; + + if (streq(h->recovery_key[j].type, "modhex64")) { + /* If this key is for a modhex64 recovery key, then try to normalize the + * passphrase to make things more robust: that way the password becomes case + * insensitive and the dashes become optional. */ + + r = normalize_recovery_key(*i, &mangled); + if (r == -EINVAL) /* Not a valid modhex64 passphrase, don't bother */ + continue; + if (r < 0) + return r; + + p = mangled; + } else + p = *i; /* Unknown recovery key types process as is */ + + r = test_password_one(h->recovery_key[j].hashed_password, p); + if (r < 0) + return r; + if (r > 0) + return 0; + } + } + + return -ENOKEY; +} + int user_record_set_disk_size(UserRecord *h, uint64_t disk_size) { _cleanup_(json_variant_unrefp) JsonVariant *new_per_machine = NULL, *midv = NULL, *midav = NULL, *ne = NULL; _cleanup_free_ JsonVariant **array = NULL; diff --git a/src/home/user-record-util.h b/src/home/user-record-util.h index 2458298825..f8bae707bc 100644 --- a/src/home/user-record-util.h +++ b/src/home/user-record-util.h @@ -31,6 +31,7 @@ enum { USER_TEST_UNDEFINED, /* Returned by user_record_test_image_path() if the storage type knows no image paths */ USER_TEST_ABSENT, USER_TEST_EXISTS, + USER_TEST_DIRTY, /* Only applies to user_record_test_image_path(), when the image exists but is marked dirty */ USER_TEST_MOUNTED, /* Only applies to user_record_test_home_directory(), when the home directory exists. */ USER_TEST_MAYBE, /* Only applies to LUKS devices: block device exists, but we don't know if it's the right one */ }; @@ -40,7 +41,8 @@ int user_record_test_home_directory_and_warn(UserRecord *h); int user_record_test_image_path(UserRecord *h); int user_record_test_image_path_and_warn(UserRecord *h); -int user_record_test_secret(UserRecord *h, UserRecord *secret); +int user_record_test_password(UserRecord *h, UserRecord *secret); +int user_record_test_recovery_key(UserRecord *h, UserRecord *secret); int user_record_update_last_changed(UserRecord *h, bool with_password); int user_record_set_disk_size(UserRecord *h, uint64_t disk_size); diff --git a/src/journal/journal-qrcode.c b/src/journal/journal-qrcode.c index 8c8360853e..e8a7655316 100644 --- a/src/journal/journal-qrcode.c +++ b/src/journal/journal-qrcode.c @@ -13,24 +13,9 @@ #include "journal-qrcode.h" #include "locale-util.h" #include "macro.h" +#include "qrcode-util.h" #include "terminal-util.h" -#define ANSI_WHITE_ON_BLACK "\033[40;37;1m" - -static void print_border(FILE *output, unsigned width) { - unsigned x, y; - - /* Four rows of border */ - for (y = 0; y < 4; y += 2) { - fputs(ANSI_WHITE_ON_BLACK, output); - - for (x = 0; x < 4 + width + 4; x++) - fputs("\342\226\210", output); - - fputs(ANSI_NORMAL "\n", output); - } -} - int print_qr_code( FILE *output, const char *prefix_text, @@ -47,7 +32,6 @@ int print_qr_code( _cleanup_free_ char *url = NULL; _cleanup_fclose_ FILE *f = NULL; size_t url_size = 0; - unsigned x, y; QRcode* qr; int r; @@ -106,40 +90,7 @@ int print_qr_code( if (prefix_text) fputs(prefix_text, output); - print_border(output, qr->width); - - for (y = 0; y < (unsigned) qr->width; y += 2) { - const uint8_t *row1, *row2; - - row1 = qr->data + qr->width * y; - row2 = row1 + qr->width; - - fputs(ANSI_WHITE_ON_BLACK, output); - for (x = 0; x < 4; x++) - fputs("\342\226\210", output); - - for (x = 0; x < (unsigned) qr->width; x ++) { - bool a, b; - - a = row1[x] & 1; - b = (y+1) < (unsigned) qr->width ? (row2[x] & 1) : false; - - if (a && b) - fputc(' ', output); - else if (a) - fputs("\342\226\204", output); - else if (b) - fputs("\342\226\200", output); - else - fputs("\342\226\210", output); - } - - for (x = 0; x < 4; x++) - fputs("\342\226\210", output); - fputs(ANSI_NORMAL "\n", output); - } - - print_border(output, qr->width); + write_qrcode(output, qr); sym_QRcode_free(qr); return 0; diff --git a/src/libsystemd/sd-bus/bus-common-errors.h b/src/libsystemd/sd-bus/bus-common-errors.h index ae80543880..965f6dd134 100644 --- a/src/libsystemd/sd-bus/bus-common-errors.h +++ b/src/libsystemd/sd-bus/bus-common-errors.h @@ -96,6 +96,7 @@ #define BUS_ERROR_HOME_ABSENT "org.freedesktop.home1.HomeAbsent" #define BUS_ERROR_HOME_BUSY "org.freedesktop.home1.HomeBusy" #define BUS_ERROR_BAD_PASSWORD "org.freedesktop.home1.BadPassword" +#define BUS_ERROR_BAD_RECOVERY_KEY "org.freedesktop.home1.BadRecoveryKey" #define BUS_ERROR_LOW_PASSWORD_QUALITY "org.freedesktop.home1.LowPasswordQuality" #define BUS_ERROR_BAD_PASSWORD_AND_NO_TOKEN "org.freedesktop.home1.BadPasswordAndNoToken" #define BUS_ERROR_TOKEN_PIN_NEEDED "org.freedesktop.home1.TokenPinNeeded" diff --git a/src/shared/meson.build b/src/shared/meson.build index 57f36af252..38762f020e 100644 --- a/src/shared/meson.build +++ b/src/shared/meson.build @@ -304,6 +304,13 @@ if conf.get('HAVE_PAM') == 1 '''.split()) endif +if conf.get('HAVE_QRENCODE') == 1 + shared_sources += files(''' + qrcode-util.c + qrcode-util.h +'''.split()) +endif + generate_ip_protocol_list = find_program('generate-ip-protocol-list.sh') ip_protocol_list_txt = custom_target( 'ip-protocol-list.txt', diff --git a/src/shared/qrcode-util.c b/src/shared/qrcode-util.c new file mode 100644 index 0000000000..a545daaef3 --- /dev/null +++ b/src/shared/qrcode-util.c @@ -0,0 +1,63 @@ +#include "qrcode-util.h" +#include "terminal-util.h" + +#define ANSI_WHITE_ON_BLACK "\033[40;37;1m" + +static void print_border(FILE *output, unsigned width) { + unsigned x, y; + + /* Four rows of border */ + for (y = 0; y < 4; y += 2) { + fputs(ANSI_WHITE_ON_BLACK, output); + + for (x = 0; x < 4 + width + 4; x++) + fputs("\342\226\210", output); + + fputs(ANSI_NORMAL "\n", output); + } +} + +void write_qrcode(FILE *output, QRcode *qr) { + unsigned x, y; + + assert(qr); + + if (!output) + output = stdout; + + print_border(output, qr->width); + + for (y = 0; y < (unsigned) qr->width; y += 2) { + const uint8_t *row1, *row2; + + row1 = qr->data + qr->width * y; + row2 = row1 + qr->width; + + fputs(ANSI_WHITE_ON_BLACK, output); + for (x = 0; x < 4; x++) + fputs("\342\226\210", output); + + for (x = 0; x < (unsigned) qr->width; x ++) { + bool a, b; + + a = row1[x] & 1; + b = (y+1) < (unsigned) qr->width ? (row2[x] & 1) : false; + + if (a && b) + fputc(' ', output); + else if (a) + fputs("\342\226\204", output); + else if (b) + fputs("\342\226\200", output); + else + fputs("\342\226\210", output); + } + + for (x = 0; x < 4; x++) + fputs("\342\226\210", output); + fputs(ANSI_NORMAL "\n", output); + } + + print_border(output, qr->width); + fflush(output); +} diff --git a/src/shared/qrcode-util.h b/src/shared/qrcode-util.h new file mode 100644 index 0000000000..9a21ffd7fe --- /dev/null +++ b/src/shared/qrcode-util.h @@ -0,0 +1,9 @@ +/* SPDX-License-Identifier: LGPL-2.1+ */ +#pragma once + +#if HAVE_QRENCODE +#include +#include + +void write_qrcode(FILE *output, QRcode *qr); +#endif diff --git a/src/shared/user-record-show.c b/src/shared/user-record-show.c index 84ededd86e..551df720ba 100644 --- a/src/shared/user-record-show.c +++ b/src/shared/user-record-show.c @@ -16,7 +16,7 @@ const char *user_record_state_color(const char *state) { return ansi_grey(); else if (streq(state, "active")) return ansi_highlight_green(); - else if (streq(state, "locked")) + else if (STR_IN_SET(state, "locked", "dirty")) return ansi_highlight_yellow(); return NULL; @@ -479,6 +479,9 @@ void user_record_show(UserRecord *hr, bool show_full_group_info) { if (hr->n_fido2_hmac_credential > 0) printf(" FIDO2 Token: %zu\n", hr->n_fido2_hmac_credential); + if (!strv_isempty(hr->recovery_key_type)) + printf("Recovery Key: %zu\n", strv_length(hr->recovery_key_type)); + k = strv_length(hr->hashed_password); if (k == 0) printf(" Passwords: %snone%s\n", diff --git a/src/shared/user-record.c b/src/shared/user-record.c index 5d9fe84061..e04df4d78b 100644 --- a/src/shared/user-record.c +++ b/src/shared/user-record.c @@ -112,6 +112,14 @@ static void fido2_hmac_salt_done(Fido2HmacSalt *s) { erase_and_free(s->hashed_password); } +static void recovery_key_done(RecoveryKey *k) { + if (!k) + return; + + free(k->type); + erase_and_free(k->hashed_password); +} + static UserRecord* user_record_free(UserRecord *h) { if (!h) return NULL; @@ -169,6 +177,10 @@ static UserRecord* user_record_free(UserRecord *h) { for (size_t i = 0; i < h->n_fido2_hmac_salt; i++) fido2_hmac_salt_done(h->fido2_hmac_salt + i); + strv_free(h->recovery_key_type); + for (size_t i = 0; i < h->n_recovery_key; i++) + recovery_key_done(h->recovery_key + i); + json_variant_unref(h->json); return mfree(h); @@ -924,6 +936,46 @@ static int dispatch_fido2_hmac_salt(const char *name, JsonVariant *variant, Json return 0; } +static int dispatch_recovery_key(const char *name, JsonVariant *variant, JsonDispatchFlags flags, void *userdata) { + UserRecord *h = userdata; + JsonVariant *e; + int r; + + if (!json_variant_is_array(variant)) + return json_log(variant, flags, SYNTHETIC_ERRNO(EINVAL), "JSON field '%s' is not an array of objects.", strna(name)); + + JSON_VARIANT_ARRAY_FOREACH(e, variant) { + RecoveryKey *array, *k; + + static const JsonDispatch recovery_key_dispatch_table[] = { + { "type", JSON_VARIANT_STRING, json_dispatch_string, 0, JSON_MANDATORY }, + { "hashedPassword", JSON_VARIANT_STRING, json_dispatch_string, offsetof(RecoveryKey, hashed_password), JSON_MANDATORY }, + {}, + }; + + if (!json_variant_is_object(e)) + return json_log(e, flags, SYNTHETIC_ERRNO(EINVAL), "JSON array element is not an object."); + + array = reallocarray(h->recovery_key, h->n_recovery_key + 1, sizeof(RecoveryKey)); + if (!array) + return log_oom(); + + h->recovery_key = array; + k = h->recovery_key + h->n_recovery_key; + *k = (RecoveryKey) {}; + + r = json_dispatch(e, recovery_key_dispatch_table, NULL, flags, k); + if (r < 0) { + recovery_key_done(k); + return r; + } + + h->n_recovery_key++; + } + + return 0; +} + static int dispatch_privileged(const char *name, JsonVariant *variant, JsonDispatchFlags flags, void *userdata) { static const JsonDispatch privileged_dispatch_table[] = { @@ -932,6 +984,7 @@ static int dispatch_privileged(const char *name, JsonVariant *variant, JsonDispa { "sshAuthorizedKeys", _JSON_VARIANT_TYPE_INVALID, json_dispatch_strv, offsetof(UserRecord, ssh_authorized_keys), 0 }, { "pkcs11EncryptedKey", JSON_VARIANT_ARRAY, dispatch_pkcs11_key, 0, 0 }, { "fido2HmacSalt", JSON_VARIANT_ARRAY, dispatch_fido2_hmac_salt, 0, 0 }, + { "recoveryKey", JSON_VARIANT_ARRAY, dispatch_recovery_key, 0, 0 }, {}, }; @@ -1475,6 +1528,7 @@ int user_record_load(UserRecord *h, JsonVariant *v, UserRecordLoadFlags load_fla { "passwordChangeNow", JSON_VARIANT_BOOLEAN, json_dispatch_tristate, offsetof(UserRecord, password_change_now), 0 }, { "pkcs11TokenUri", JSON_VARIANT_ARRAY, dispatch_pkcs11_uri_array, offsetof(UserRecord, pkcs11_token_uri), 0 }, { "fido2HmacCredential", JSON_VARIANT_ARRAY, dispatch_fido2_hmac_credential_array, 0, 0 }, + { "recoveryKeyType", JSON_VARIANT_ARRAY, json_dispatch_strv, offsetof(UserRecord, recovery_key_type), 0 }, { "secret", JSON_VARIANT_OBJECT, dispatch_secret, 0, 0 }, { "privileged", JSON_VARIANT_OBJECT, dispatch_privileged, 0, 0 }, diff --git a/src/shared/user-record.h b/src/shared/user-record.h index 39580b6b76..bbcb2201e8 100644 --- a/src/shared/user-record.h +++ b/src/shared/user-record.h @@ -206,6 +206,14 @@ typedef struct Fido2HmacSalt { char *hashed_password; } Fido2HmacSalt; +typedef struct RecoveryKey { + /* The type of recovery key, must be "modhex64" right now */ + char *type; + + /* A UNIX pasword hash of the normalized form of modhex64 */ + char *hashed_password; +} RecoveryKey; + typedef struct UserRecord { /* The following three fields are not part of the JSON record */ unsigned n_ref; @@ -332,6 +340,10 @@ typedef struct UserRecord { size_t n_fido2_hmac_salt; int fido2_user_presence_permitted; + char **recovery_key_type; + RecoveryKey *recovery_key; + size_t n_recovery_key; + JsonVariant *json; } UserRecord;