Skip to content

keys gen-kxchap: the DHHC-1 payload is the spec's *secret*, not a *key* — and there is no way to emit the representation of a given secret #3713

Description

@kariya-mitsuru

Environment

  • nvme-cli master @ ab7358832710e789bc852df80bd40732a8b205a4 (version 3.0-b.4)
  • Reference: NVM Express Base Specification, Revision 2.3, sections 8.3.5.5.7 and 8.3.5.5.8

The same behaviour is present in 2.x, where the code lives in gen_dhchap_key() in nvme.c rather than in gen_kxchap() / libnvmf_gen_kxchap_key().

Summary

DHHC-1:xx:<Base64>: is defined by the specification as a representation of a secret. nvme-cli treats it as a representation of a key: it runs the secret-to-key transform at generation time and puts the transformed value in the Base64 payload.

The resulting string is still a well-formed, interoperable DHHC-1 secret representation, so this is not an interoperability bug — every consumer, including the Linux kernel, reads the payload as a secret and applies the transform exactly once. But the naming and the resulting behaviour are confusing in a way that has two concrete consequences, described below.

Raising this now because 3.0 is still in beta and the command is being renamed (gen-dhchap-keykeys gen-kxchap) anyway, so this seems like the natural moment to settle the terminology.

1. What the specification says

Section 8.3.5.5.8 is titled "Secret Representation", and defines the format as:

DHHC-1:xx:<Base64 encoded string>:

  • xx indicates the hash function to be used to transform the secret in key (refer to section 8.3.5.5.7) [...] The two ASCII characters "00" indicate no transform (i.e., use the secret as a key); and
  • The Base64 (refer to RFC 4648) string encodes the secret (32, 48, or 64 bytes binary) followed by the CRC-32 (refer to RFC 1952) of the secret (4 bytes binary).

Section 8.3.5.5.7 defines the transform that the consumer performs:

transforming the provided secret into a key applying the HMAC function using the hash function specified in the secret representation [...] (i.e., key = HMAC(secret, NQN || "NVMe-over-Fabrics"))

So the payload is the secret, xx is an instruction to the consumer, and the key is derived by the consumer at use time. Nothing in the specification puts a key inside this string.

2. nvme-cli is internally inconsistent about this

The consuming side already uses the correct term. Documentation/fabrics-options.txt:

-S <secret>::
--kxchap-secret=<secret>::
	Host authentication secret (KX-HMAC-CHAP). Must be provided in ASCII
	format as defined in the NVMe specification.

The producing side calls the identical string a key. plugins/keys/keys-plugin.c:

const char *desc =
    "Generate a KX-HMAC-CHAP host key usable for NVMe In-Band Authentication.";
const char *secret =
    "Optional secret (in hexadecimal characters) to be used to initialize the host key.";
const char *nqn = "Host NQN to use for key transformation.";

This inverts the specification's vocabulary: nvme-cli uses "secret" for the raw input to -s, and "key" for the DHHC-1 output — whereas in the specification the DHHC-1 payload is the secret, and the key is something neither produced nor consumed in this format.

3. Consequence A: a given secret's representation cannot be produced

gen_kxchap() always applies the transform when -m is non-zero:

err = libnvmf_gen_kxchap_key(ctx, cfg.nqn, cfg.hmac,
	cfg.key_len, raw_secret, key);
if (err)
	return err;

crc = shr_crc32(crc, key, cfg.key_len);
...
nvme_show_result("DHHC-1:%02x:%s:", cfg.hmac, encoded_key);

and libnvmf_gen_kxchap_key() in libnvme/src/nvme/crypto.c computes HMAC(secret, hostnqn || "NVMe-over-Fabrics").

This means -s does not do what its name suggests. Given a secret S provisioned by an external key-management system, there is no way to ask nvme-cli for the DHHC-1 representation of S with a hash function selected.

Worked example — S = 000102...1f (32 bytes), host NQN nqn.2014-08.org.nvmexpress:uuid:41169f02-24ef-4eb8-b441-a7a6f3f6b9b4:

representation of S per 8.3.5.5.8 with xx=01:
  DHHC-1:01:AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh+KfiaR:

what `nvme keys gen-kxchap -m 1 -s <S> -n <nqn>` emits:
  DHHC-1:01:f4v0JG7r+CI4EOmgU6fny7s6N0/aR6KYJY8mx60+tRrA0EHJ:

Reproducible with:

import hmac, hashlib, base64, zlib

S    = bytes(range(32))
nqn  = "nqn.2014-08.org.nvmexpress:uuid:41169f02-24ef-4eb8-b441-a7a6f3f6b9b4"

def dhhc(xx, payload):
    crc = zlib.crc32(payload)
    return "DHHC-1:%02x:%s:" % (
        xx, base64.b64encode(payload + crc.to_bytes(4, "little")).decode())

print(dhhc(1, S))                                                   # per spec
print(dhhc(1, hmac.new(S, (nqn + "NVMe-over-Fabrics").encode(),
                       hashlib.sha256).digest()))                   # nvme-cli

The only workaround today is -m 0 -s <S> followed by hand-editing the 00 to 01, which happens to work because xx sits outside the Base64 and the CRC-32 covers the payload only.

4. Consequence B: -n/--nqn is misleading

-n is documented as "Host NQN to use for key transformation", which reasonably reads as "the generated credential is bound to this NQN". It is not. The NQN binding required by 8.3.5.5.7 happens at use time, in the consumer, against whatever NQN that entity actually has. In the kernel, drivers/nvme/common/auth.c:

nvme_auth_hmac_update(&hmac, nqn, strlen(nqn));
nvme_auth_hmac_update(&hmac, "NVMe-over-Fabrics", 17);

called from drivers/nvme/host/auth.c as nvme_auth_transform_key(ctrl->host_key, ctrl->opts->host->nqn).

So the value passed to -n has no effect on where the generated string can be used — it only changes which random bytes end up as the secret. A string generated with -n hostA works unmodified on hostB, contradicting the documented intent.

As a side observation, the Linux generate-then-connect path therefore applies the transform twice, HMAC(HMAC(rand, nqn || seed), nqn || seed). This is harmless and symmetric across host and target, and I am not reporting it as a defect — noting it only because it makes the layering easier to see.

Possible directions

Not attached to any particular one; listing what seems reasonable:

  1. Documentation and help text only. Keep the behaviour, but describe the output as a secret representation, and describe -n/-m as affecting how the random secret is derived rather than implying the output is a key or is bound to an NQN. Lowest risk, resolves most of the confusion.
  2. Emit the plain representation when a secret is supplied. With -s, treat the input as the secret and emit DHHC-1:xx:base64(S || crc): without the extra HMAC, keeping the current derivation for the random case. Fixes Consequence A. Changes output for existing -s users, so it would want to land before 3.0 goes stable.
  3. An explicit flag (e.g. --no-derive) selecting representation-only behaviour, leaving the default untouched.

If option 2 or 3 is taken, the hash/length pairing enforced in gen_kxchap() would need revisiting for that path, since the length of an externally supplied secret is not ours to choose. The pairing makes sense for the random-generation path and I would leave it alone there.

Prior discussion

For what it is worth, I went back to the original submission on linux-nvme (September 2021) to check whether this was a deliberate design decision. The review thread exists but covers two other topics only:

The discussion is about whether /etc/nvme/hostkey should be created at install time, and about supporting per-subsystem keys. The placement of the secret-to-key transform, and the "secret" vs "key" terminology, do not appear to have been raised at the time — the term "host key" is used throughout without being questioned. So this may simply be an accident of the original wording rather than a considered decision, but I may well have missed a discussion elsewhere.

Questions

  • Was putting the transformed value in the payload deliberate? If there is rationale I have not found, a pointer would be appreciated.
  • Is renaming/redocumenting in scope for 3.0, given the command is being renamed already?

I am happy to send patches for whichever direction is preferred.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions