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-key → keys 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:
- 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.
- 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.
- 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.
Environment
master@ab7358832710e789bc852df80bd40732a8b205a4(version3.0-b.4)The same behaviour is present in 2.x, where the code lives in
gen_dhchap_key()innvme.crather than ingen_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-key→keys 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:
Section 8.3.5.5.7 defines the transform that the consumer performs:
So the payload is the secret,
xxis 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:The producing side calls the identical string a key.
plugins/keys/keys-plugin.c: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-mis non-zero:and
libnvmf_gen_kxchap_key()inlibnvme/src/nvme/crypto.ccomputesHMAC(secret, hostnqn || "NVMe-over-Fabrics").This means
-sdoes not do what its name suggests. Given a secretSprovisioned by an external key-management system, there is no way to ask nvme-cli for the DHHC-1 representation ofSwith a hash function selected.Worked example —
S = 000102...1f(32 bytes), host NQNnqn.2014-08.org.nvmexpress:uuid:41169f02-24ef-4eb8-b441-a7a6f3f6b9b4:Reproducible with:
The only workaround today is
-m 0 -s <S>followed by hand-editing the00to01, which happens to work becausexxsits outside the Base64 and the CRC-32 covers the payload only.4. Consequence B:
-n/--nqnis misleading-nis 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:called from
drivers/nvme/host/auth.casnvme_auth_transform_key(ctrl->host_key, ctrl->opts->host->nqn).So the value passed to
-nhas 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 hostAworks unmodified onhostB, 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:
-n/-mas 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.-s, treat the input as the secret and emitDHHC-1:xx:base64(S || crc):without the extra HMAC, keeping the current derivation for the random case. Fixes Consequence A. Changes output for existing-susers, so it would want to land before 3.0 goes stable.--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/hostkeyshould 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
I am happy to send patches for whichever direction is preferred.