diff --git a/man/repart.d.xml b/man/repart.d.xml index 3e012f8619..7120a466a2 100644 --- a/man/repart.d.xml +++ b/man/repart.d.xml @@ -773,6 +773,59 @@ + + EncryptAddToken= + + Registers a custom LUKS2 token in the partition's LUKS2 header at provisioning + time. This option may be specified more than once to register multiple tokens. Each invocation adds + one token entry. Assigning the empty string clears all previously assigned tokens. + + The syntax is: + EncryptAddToken=type[:source:value] + + The type field is mandatory and specifies the LUKS2 token type + name (e.g. tdx-kbs). It must be a valid filename component (no + /, no . or ..) and corresponds to the + name of the token handler plugin installed as + libcryptsetup-token-type.so. + + The optional source and value fields + supply token-plugin-specific metadata as a JSON object. Two sources are supported: + + + + file + The value is a path to a file containing a JSON + object with the extra token metadata. Example: + EncryptAddToken=tdx-kbs:file:/etc/repart/tdx-kbs-token.json + + + + + data + The value is a Base64-encoded JSON object with the + extra token metadata, suitable for embedding directly in the configuration file. Example: + EncryptAddToken=tdx-kbs:data:eyJ0cnVzdGVlLmticy51cmwiOiJodHRwOi8va2JzLXNlcnZpY2U6ODA4MCJ9 + + + + + The extra metadata object must not contain type or + keyslots fields — these are always set by + systemd-repart: type is taken from the + type argument, and keyslots is set to the keyslot + number created for this partition. + + This option is supported with all Encrypt= modes except + off. When used with key-file+tpm2, the token is bound to the + key-file keyslot. When used with tpm2, the token is bound to the TPM2 + keyslot. + + This option has no effect if the partition already exists. + + + + Verity= diff --git a/src/repart/repart.c b/src/repart/repart.c index 388f25c398..d042b3255f 100644 --- a/src/repart/repart.c +++ b/src/repart/repart.c @@ -350,6 +350,11 @@ typedef struct PartitionEncryptedVolume { bool fixate_volume_key; } PartitionEncryptedVolume; +typedef struct PartitionEncryptAddToken { + char *type; /* token type name, always set */ + sd_json_variant *data; /* extra fields (excluding type/keyslots), may be NULL */ +} PartitionEncryptAddToken; + static PartitionEncryptedVolume* partition_encrypted_volume_free(PartitionEncryptedVolume *c) { if (!c) return NULL; @@ -361,6 +366,19 @@ static PartitionEncryptedVolume* partition_encrypted_volume_free(PartitionEncryp return mfree(c); } +static void partition_encrypt_add_token_done(PartitionEncryptAddToken *t) { + assert(t); + free(t->type); + sd_json_variant_unref(t->data); +} + +static void partition_encrypt_add_token_free_many(PartitionEncryptAddToken *tokens, size_t n) { + assert(tokens || n == 0); + FOREACH_ARRAY(t, tokens, n) + partition_encrypt_add_token_done(t); + free(tokens); +} + typedef struct CopyFiles { char *source; char *target; @@ -545,6 +563,8 @@ typedef struct Partition { size_t n_mountpoints; PartitionEncryptedVolume *encrypted_volume; + PartitionEncryptAddToken *encrypt_add_tokens; + size_t n_encrypt_add_tokens; unsigned last_percent; RateLimit progress_ratelimit; @@ -873,6 +893,7 @@ static Partition* partition_free(Partition *p) { p->n_mountpoints = 0; partition_encrypted_volume_free(p->encrypted_volume); + partition_encrypt_add_token_free_many(p->encrypt_add_tokens, p->n_encrypt_add_tokens); partition_unlink_supplement(p); @@ -933,6 +954,9 @@ static void partition_foreignize(Partition *p) { p->n_mountpoints = 0; p->encrypted_volume = partition_encrypted_volume_free(p->encrypted_volume); + partition_encrypt_add_token_free_many(p->encrypt_add_tokens, p->n_encrypt_add_tokens); + p->encrypt_add_tokens = NULL; + p->n_encrypt_add_tokens = 0; partition_unlink_supplement(p); } @@ -2694,7 +2718,7 @@ static int config_parse_encrypted_volume( int r; if (isempty(rvalue)) { - p->encrypted_volume = mfree(p->encrypted_volume); + p->encrypted_volume = partition_encrypted_volume_free(p->encrypted_volume); return 0; } @@ -2761,6 +2785,139 @@ static int config_parse_encrypted_volume( return 0; } +static int config_parse_encrypt_add_token( + const char *unit, + const char *filename, + unsigned line, + const char *section, + unsigned section_line, + const char *lvalue, + int ltype, + const char *rvalue, + void *data, + void *userdata) { + + Partition *p = ASSERT_PTR(data); + int r; + + assert(rvalue); + + if (isempty(rvalue)) { + partition_encrypt_add_token_free_many(p->encrypt_add_tokens, p->n_encrypt_add_tokens); + p->encrypt_add_tokens = NULL; + p->n_encrypt_add_tokens = 0; + return 0; + } + + /* Syntax: EncryptAddToken=[::] + * where source is "file" (path) or "data" (base64-encoded JSON). + * The token type name is always supplied explicitly and stored separately. + * Extra metadata may be supplied as: + * :file:/path/to/metadata.json — read JSON from file + * :data: — inline base64-encoded JSON + */ + + _cleanup_free_ char *type = NULL, *source = NULL; + const char *q = rvalue; + + /* Extract only type and source; leave the rest of q as the value verbatim. + * This allows ':' in the value (e.g. file paths containing colons). */ + r = extract_many_words(&q, ":", EXTRACT_DONT_COALESCE_SEPARATORS, &type, &source); + if (r == -ENOMEM) + return log_oom(); + if (r < 0) + return log_syntax(unit, LOG_ERR, filename, line, r, + "Failed to parse %s=: %s", lvalue, rvalue); + if (r < 1) + return log_syntax(unit, LOG_ERR, filename, line, SYNTHETIC_ERRNO(EINVAL), + "%s= requires at least a token type name.", lvalue); + + /* Validate the type name: it becomes the suffix of libcryptsetup-token-.so, so it must + * be a valid filename component — no slashes, no '.' or '..' path traversal. */ + if (isempty(type) || dot_or_dot_dot(type) || !filename_part_is_valid(type)) + return log_syntax(unit, LOG_ERR, filename, line, SYNTHETIC_ERRNO(EINVAL), + "Invalid token type name in %s= (must be a valid filename component): %s", lvalue, type); + + /* Refuse the token type names systemd own cryptsetup token plugins use */ + if (STR_IN_SET(type, "systemd-tpm2", "systemd-fido2", "systemd-pkcs11", "systemd-recovery")) + return log_syntax(unit, LOG_ERR, filename, line, SYNTHETIC_ERRNO(EINVAL), + "Token type name in %s= is reserved for systemd's own use: %s", lvalue, type); + + _cleanup_(sd_json_variant_unrefp) sd_json_variant *extra = NULL; + + if (source) { + /* EXTRACT_DONT_COALESCE_SEPARATORS yields an empty-string source for inputs like + * 'type:' (trailing colon) or 'type::value' (double colon) — reject explicitly. */ + if (isempty(source)) + return log_syntax(unit, LOG_ERR, filename, line, SYNTHETIC_ERRNO(EINVAL), + "Empty source in %s=, expected 'file' or 'data': %s", lvalue, rvalue); + /* Value is the remainder of the input after source, so ':' in it is preserved. */ + if (isempty(q)) + return log_syntax(unit, LOG_ERR, filename, line, SYNTHETIC_ERRNO(EINVAL), + "%s= source '%s' requires a value.", lvalue, source); + + _cleanup_free_ char *json_text = NULL; + + if (streq(source, "file")) { + if (!path_is_absolute(q)) + return log_syntax(unit, LOG_ERR, filename, line, SYNTHETIC_ERRNO(EINVAL), + "Path in %s= is not absolute: %s", lvalue, q); + + /* Load JSON from a file; cap at 64K — token metadata is tiny */ + r = read_full_file_full( + AT_FDCWD, q, + /* offset= */ UINT64_MAX, + /* size= */ 64 * 1024, + READ_FULL_FILE_FAIL_WHEN_LARGER | READ_FULL_FILE_VERIFY_REGULAR, + /* bind_name= */ NULL, + &json_text, /* ret_size= */ NULL); + if (r < 0) + return log_syntax(unit, LOG_ERR, filename, line, r, + "Failed to read token data file '%s': %m", q); + } else if (streq(source, "data")) { + /* Decode base64-encoded JSON */ + _cleanup_free_ void *decoded = NULL; + size_t decoded_size = 0; + r = unbase64mem(q, &decoded, &decoded_size); + if (r < 0) + return log_syntax(unit, LOG_ERR, filename, line, r, + "Failed to decode base64 token data in %s=: %m", lvalue); + /* JSON cannot contain raw NUL bytes; reject early rather than silently truncating */ + if (memchr(decoded, '\0', decoded_size)) + return log_syntax(unit, LOG_ERR, filename, line, SYNTHETIC_ERRNO(EINVAL), + "Base64-decoded token data in %s= contains embedded NUL bytes.", lvalue); + json_text = strndup(decoded, decoded_size); + if (!json_text) + return log_oom(); + } else + return log_syntax(unit, LOG_ERR, filename, line, SYNTHETIC_ERRNO(EINVAL), + "Unknown source '%s' in %s=, expected 'file' or 'data'.", source, lvalue); + + r = sd_json_parse(json_text, SD_JSON_PARSE_MUST_BE_OBJECT, &extra, NULL, NULL); + if (r < 0) + return log_syntax(unit, LOG_ERR, filename, line, r, + "Token data in %s= is not a valid JSON object: %m", lvalue); + + /* Reject any pre-set "type" or "keyslots" — we manage those */ + if (sd_json_variant_by_key(extra, "type")) + return log_syntax(unit, LOG_ERR, filename, line, SYNTHETIC_ERRNO(EINVAL), + "Token data in %s= must not contain a \"type\" field (set by the type name argument).", lvalue); + if (sd_json_variant_by_key(extra, "keyslots")) + return log_syntax(unit, LOG_ERR, filename, line, SYNTHETIC_ERRNO(EINVAL), + "Token data in %s= must not contain a \"keyslots\" field (set automatically).", lvalue); + } + + if (!GREEDY_REALLOC(p->encrypt_add_tokens, p->n_encrypt_add_tokens + 1)) + return log_oom(); + + p->encrypt_add_tokens[p->n_encrypt_add_tokens++] = (PartitionEncryptAddToken) { + .type = TAKE_PTR(type), + .data = TAKE_PTR(extra), + }; + + return 0; +} + static int config_parse_tpm2_pcrs( const char *unit, const char *filename, @@ -2995,6 +3152,7 @@ static int partition_read_definition( { "Partition", "VerityHashBlockSizeBytes", config_parse_block_size, 0, &p->verity_hash_block_size }, { "Partition", "MountPoint", config_parse_mountpoint, 0, p }, { "Partition", "EncryptedVolume", config_parse_encrypted_volume, 0, p }, + { "Partition", "EncryptAddToken", config_parse_encrypt_add_token, 0, p }, { "Partition", "TPM2PCRs", config_parse_tpm2_pcrs, 0, p }, { "Partition", "KeyFile", config_parse_key_file, 0, p }, { "Partition", "Integrity", config_parse_integrity, 0, &p->integrity }, @@ -3208,6 +3366,10 @@ static int partition_read_definition( log_syntax(NULL, LOG_WARNING, path, 1, 0, "EncryptKDF= has no effect with Encrypt=off."); + if (p->n_encrypt_add_tokens > 0 && p->encrypt == ENCRYPT_OFF) + log_syntax(NULL, LOG_WARNING, path, 1, 0, + "EncryptAddToken= has no effect with Encrypt=off."); + if (p->encrypt != ENCRYPT_OFF && p->integrity == INTEGRITY_INLINE && p->discard > 0) return log_syntax(NULL, LOG_ERR, path, 1, SYNTHETIC_ERRNO(EINVAL), "Integrity=inline is incompatible with Discard=yes."); @@ -5475,6 +5637,39 @@ static size_t dmcrypt_proper_key_size(Partition *p) { } } +#if HAVE_LIBCRYPTSETUP +static int partition_add_tokens_for_keyslot(Partition *p, struct crypt_device *cd, int keyslot) { + int r; + + assert(p); + assert(cd); + + char keyslot_str[DECIMAL_STR_MAX(int)]; + xsprintf(keyslot_str, "%i", keyslot); + + FOREACH_ARRAY(t, p->encrypt_add_tokens, p->n_encrypt_add_tokens) { + _cleanup_(sd_json_variant_unrefp) sd_json_variant *v = NULL; + + /* Start with extra data if any (NULL-safe), then inject type and keyslots. + * merge_objectbo handles a blank/NULL *v by replacing it with the merged object, + * so this works whether or not t->data is set. */ + v = sd_json_variant_ref(t->data); + r = sd_json_variant_merge_objectbo(&v, + SD_JSON_BUILD_PAIR_STRING("type", t->type), + SD_JSON_BUILD_PAIR("keyslots", + SD_JSON_BUILD_ARRAY(SD_JSON_BUILD_STRING(keyslot_str)))); + if (r < 0) + return log_error_errno(r, "Failed to build LUKS2 token JSON for type '%s': %m", t->type); + + r = cryptsetup_add_token_json(cd, v); + if (r < 0) + return log_error_errno(r, "Failed to add LUKS2 token '%s' to header: %m", t->type); + } + + return 0; +} +#endif + static int partition_encrypt(Context *context, Partition *p, PartitionTarget *target, bool offline, bool temporary) { #if HAVE_LIBCRYPTSETUP #if HAVE_TPM2 @@ -5679,8 +5874,14 @@ static int partition_encrypt(Context *context, Partition *p, PartitionTarget *ta if (r < 0) return log_error_errno(r, "Failed to add LUKS2 key: %m"); + int key_file_keyslot = r; + passphrase = strempty(iovec_key->iov_base); passphrase_size = iovec_key->iov_len; + + r = partition_add_tokens_for_keyslot(p, cd, key_file_keyslot); + if (r < 0) + return r; } if (IN_SET(p->encrypt, ENCRYPT_TPM2, ENCRYPT_KEY_FILE_TPM2)) { @@ -5886,6 +6087,14 @@ static int partition_encrypt(Context *context, Partition *p, PartitionTarget *ta if (r < 0) return log_error_errno(r, "Failed to add TPM2 JSON token to LUKS2 header: %m"); + /* For key-file+tpm2 the custom tokens were already bound to the key-file keyslot + * above; only bind them to the TPM2 keyslot in pure TPM2 mode. */ + if (p->encrypt == ENCRYPT_TPM2) { + r = partition_add_tokens_for_keyslot(p, cd, keyslot); + if (r < 0) + return r; + } + passphrase = base64_encoded; passphrase_size = strlen(base64_encoded); #else diff --git a/test/units/TEST-58-REPART.sh b/test/units/TEST-58-REPART.sh index c862c07d2e..05b1090cc0 100755 --- a/test/units/TEST-58-REPART.sh +++ b/test/units/TEST-58-REPART.sh @@ -2940,6 +2940,192 @@ EOF losetup -d "$loop" } +testcase_encrypt_add_token() { + local defs imgs loop token_json metadata_json metadata_b64 + + defs="$(mktemp --directory "/tmp/test-repart.defs.XXXXXXXXXX")" + imgs="$(mktemp --directory "/var/tmp/test-repart.imgs.XXXXXXXXXX")" + # shellcheck disable=SC2064 + trap "rm -rf '$defs' '$imgs'" RETURN + chmod 0755 "$defs" + + echo "*** testcase for EncryptAddToken= (plain type name, no extra data) ***" + + tee "$defs/root.conf" </dev/null + + token_json="$(cryptsetup token export --token-id 0 "${loop}p1")" + echo "$token_json" | python3 -c " +import json, sys +d = json.load(sys.stdin) +assert d['type'] == 'test-dummy-token', 'Wrong token type: {}'.format(d['type']) +assert '0' in d.get('keyslots', []), 'keyslot 0 not referenced by token: {}'.format(d) +" + + losetup -d "$loop" + # shellcheck disable=SC2064 + trap "rm -rf '$defs' '$imgs'" RETURN + + echo "*** testcase for EncryptAddToken= (type + extra data via :data: base64) ***" + + rm -f "$defs/root.conf" + + # Build base64-encoded JSON for the extra metadata (no type/keyslots) + metadata_json='{"trustee.kbs.url":"http://kbs-service:8080"}' + metadata_b64="$(echo -n "$metadata_json" | base64 -w0)" + + tee "$defs/root.conf" </dev/null + + token_json="$(cryptsetup token export --token-id 0 "${loop}p1")" + echo "$token_json" | python3 -c " +import json, sys +d = json.load(sys.stdin) +assert d['type'] == 'test-dummy-token', 'Wrong token type: {}'.format(d['type']) +assert d.get('trustee.kbs.url') == 'http://kbs-service:8080', 'Extra field not preserved: {}'.format(d) +assert '0' in d.get('keyslots', []), 'keyslot 0 not referenced by token: {}'.format(d) +" + + losetup -d "$loop" + # shellcheck disable=SC2064 + trap "rm -rf '$defs' '$imgs'" RETURN + + echo "*** testcase for EncryptAddToken= (type + extra data via :file:) ***" + + rm -f "$defs/root.conf" + + echo -n '{"trustee.kbs.url":"http://kbs-service:9090"}' > "$imgs/token-meta.json" + + tee "$defs/root.conf" </dev/null + cryptsetup luksDump "${loop}p1" | grep "second-token" >/dev/null + + # Verify each token individually + cryptsetup token export --token-id 0 "${loop}p1" | python3 -c " +import json, sys +d = json.load(sys.stdin) +assert d['type'] == 'first-token', 'Wrong first token type: {}'.format(d['type']) +assert '0' in d.get('keyslots', []), 'keyslot not in first token: {}'.format(d) +" + cryptsetup token export --token-id 1 "${loop}p1" | python3 -c " +import json, sys +d = json.load(sys.stdin) +assert d['type'] == 'second-token', 'Wrong second token type: {}'.format(d['type']) +assert d.get('extra-key') == 'extra-val', 'Extra field missing in second token: {}'.format(d) +assert '0' in d.get('keyslots', []), 'keyslot not in second token: {}'.format(d) +" + + losetup -d "$loop" + # shellcheck disable=SC2064 + trap "rm -rf '$defs' '$imgs'" RETURN +} + testcase_fstab_crypttab_in_repart() { local defs imgs root volume