feat: encryption at rest for dm-thin volumes (LUKS2/dm-crypt) - #67
feat: encryption at rest for dm-thin volumes (LUKS2/dm-crypt)#67abonillabeeche wants to merge 4 commits into
Conversation
- Never format an LV when filesystem detection fails - Remove remaining panic and unchecked-dereference paths - Make all lifecycle operations genuinely idempotent - Correct mount and unmount semantics Signed-off-by: Webber Huang <webberhuang@suse.com>
Signed-off-by: Webber Huang <webber.huang@suse.com>
Add opt-in encryption at rest for LVM volumes, layered on top of the volume/snapshot lifecycle hardening in harvester#64 and the snapshot record-location work in harvester#66. Design ------ Encryption is declared on the StorageClass (`encrypted: "true"`) and follows the same CSI-secret + CRYPTO_KEY_* convention Longhorn uses, so the existing Harvester webhook and dashboard accept it unchanged: - CRYPTO_KEY_VALUE (passphrase, required) - CRYPTO_KEY_CIPHER (default aes-xts-plain64) - CRYPTO_KEY_HASH (default sha256) - CRYPTO_KEY_SIZE (default 256) - CRYPTO_PBKDF (default argon2i) Each volume is LUKS2-formatted and opened as a dm-crypt device (/dev/mapper/csi-lvm-<volID>) at NodePublishVolume; mount/bind-mount/expand all operate through the mapper. The passphrase is fed to cryptsetup over stdin, never via argv. Unpublish closes the mapper; expand resizes the LUKS device. Changes ------- - pkg/lvm/encryption.go: LUKS2 format/open/close/resize helpers, cryptExecutor with stdin passphrase, mapper gating, isLuks/status probing. - pkg/lvm/nodeserver.go: extract CRYPTO_KEY_* params and open/close the dm-crypt device around publish/unpublish/expand. - pkg/lvm/lvm.go: mount/bind-mount/resize take a resolved device path so encrypted volumes act on the mapper. - package/Dockerfile: install cryptsetup in the node plugin image. - deploy/charts/templates/rbac.yaml: grant the controller SA (external- provisioner) cluster-wide secrets get/list/watch to resolve the provisioner-secret (see PR description RBAC question). - examples/storageclass-dm-thin-encrypted.yaml, README.md: usage docs; the example SC sets cloneStrategy=copy so CDI image clones write through LUKS. - unit tests for the crypto param parsing and cryptsetup argument construction. Signed-off-by: Alejandro Bonilla <abonilla@suse.com>
a4c791b to
b443882
Compare
The StorageClass creation form only rendered the encryption toggle + secret picker for the Longhorn provisioner components; the LVM provisioner form had no way to enable encryption. Add the same encryption UI to the LVM provisioner so users can create encrypted lvm.driver.harvesterhci.io StorageClasses from the dashboard. The block mirrors provisioners/driver.longhorn.io_v1.vue: a Volume Encryption RadioGroup bound to parameters.encrypted, and a Secret LabeledSelect that populates the csi.storage.k8s.io provisioner/node-publish/node-stage (and node-expand, when the online-expansion feature is enabled) secret parameters from CSI_SECRETS. It is gated on the existing `volumeEncryption` feature flag (value.volumeEncryptionFeatureEnabled) and reuses the secret prefetch already done in index.vue. Encrypted params are stripped from the raw KeyValue editor to avoid duplicate entry. Everything downstream (VM images, volumes, VMs, snapshot/backup restore) already recognizes an encrypted StorageClass generically via StorageClass.isEncrypted (parameters.encrypted === 'true'), so no other UI changes are needed. Companion driver support: harvester/csi-driver-lvm#67. Signed-off-by: Alejandro Bonilla <abonilla@suse.com>
Evidence that IO is routed through the dm-crypt layerRe: the concern about whether the volume's IO actually passes through the encryption layer (vs. a LUKS header that's present but bypassed). Here's a plaintext-in / ciphertext-on-disk differential run on a live cluster. Setup: a plain pod wrote a known marker ( 1. The workload's device is a LUKS2 crypt target sitting on the raw LV 2. Differential — same bytes, read below the crypt layer vs through the mapper
First data sector of the raw LV is high-entropy ( The plaintext is visible only through the dm-crypt mapper; on the backing LV it is ciphertext. That is only possible if every read/write the workload issues traverses the crypt target — i.e. IO is genuinely routed through the encryption layer. Reproduce (any encrypted PVC, on its node): MAP=/dev/mapper/csi-lvm-<volumeHandle>
RAW=$(cryptsetup status $MAP | awk '/device:/{print $2}')
# write a marker through the mapper (e.g. from a pod using the PVC), then:
dd if=$RAW bs=1M count=256 | grep -a -c MARKER # -> 0 (ciphertext on disk)
dd if=$MAP bs=1M count=32 | grep -a -c MARKER # -> >0 (plaintext via crypt) |
Boot-time & power-cycle testing: encrypted vs plain VMFollow-up to the fio benchmark and the dm-crypt IO-path proof above. Both VMs are identical Ubuntu 24.04 (noble) guests scheduled on the same node from the same base image; the only difference is the StorageClass ( Guest boot time (
|
| VM | Kernel | Userspace | Total |
|---|---|---|---|
| encrypted | 4.389s | 6.833s | 11.223s |
| plain | 4.449s | 6.650s | 11.100s |
| encrypted (post-reboot re-check) | 4.394s | 7.002s | 11.397s |
Difference is ~1%, within run-to-run noise. Top systemd-analyze blame units are identical on both (systemd-networkd-wait-online, cloud-config, snapd). This is expected: dm-crypt runs on the host (luksOpen at NodeStage, transparent block layer), so the guest sees a plain vda and its boot path is unaffected. lsblk inside both guests confirms an ordinary vda with no crypt device visible in-guest.
Off/On and reboot (wall-clock, virtctl start/restart → VMI Ready)
| Operation | encrypted | plain |
|---|---|---|
| Off → On (stop/start) | 10s | 8s |
| Reboot #1 | 11s | 6s |
| Reboot #2 | 11s | 8s |
Every cycle succeeded; both VMs returned to Running/Ready reliably. The encrypted VM is consistently ~2-4s slower at the infra level — that gap is the host-side cryptsetup luksOpen (Argon2i PBKDF) at attach time, a fixed one-time cost per attach, not a per-IO tax.
Log health
- 0 failed systemd units on both VMs.
- Only benign, identical warnings on both (
pam_lastlog.sodlopen — a known Ubuntu 24.04 packaging quirk — and theITS mitigationkernel notice). No storage, dm-crypt, or filesystem errors.
Summary
Encryption at rest adds negligible guest-boot overhead (~1%) plus a small fixed attach-time cost (~2-4s) for the LUKS unlock. Stop/start and reboot are robust across repeated cycles. Consistent with the fio result (~1% throughput overhead via AES-NI): encryption is effectively free at runtime, the only measurable cost being the one-time unlock at volume attach.
Disk performance: encrypted vs. plain (and a
|
| Config | IOPS | MB/s | avg lat (ms) |
|---|---|---|---|
| Plain (unencrypted) | ~140,500 / ~141,300 | ~549 / ~552 | 0.89 / 0.87 |
| Encrypted, default (queued kcryptd read) | ~71,400 / ~71,200 | ~279 / ~278 | 1.78 / 1.78 |
Encrypted, --perf-no_read_workqueue |
66,076 / 51,697 | 258 / 202 | 1.91 / 2.45 |
Encrypted 4K random-read runs at roughly half the plain IOPS at this queue depth. Writes and sequential transfers were within run-to-run noise between plain and encrypted (encrypted 4K random-write ~14.7–15.7k IOPS vs plain ~12–21k across samples; both sequential cases dominated by thin-pool variance).
The --perf-no_read_workqueue experiment
Hypothesis: at high read IOPS the bounded kcryptd read workqueue — not AES-NI throughput — is the limiter, so activating the LUKS device with --perf-no_read_workqueue (inline decryption on the submitting task) should recover most of the gap. It's an activation-time flag only, no on-disk format change.
I built a driver image that adds the flag to cryptsetup luksOpen, deployed it, and confirmed it was live (the dm-crypt table showed ... 1 no_read_workqueue on the benchmarked device). Result: it did not help — it slightly hurt. Two samples gave 66,076 and 51,697 IOPS, at or below the stable ~71k of the default queued path, versus ~140k plain.
Why (interpretation): with only 4 vCPU and QD32×4 (128 outstanding I/Os), the bottleneck is CPU, not the read workqueue. Inline decryption serializes the decrypt onto the submitting CPUs, whereas the default path fans decryption across kcryptd workers — so removing the workqueue reduces available parallelism and throughput goes down. The read penalty is inherent dm-crypt cost at high IOPS on a low-vCPU guest, not a workqueue-scheduling artifact.
Decision: the flag is not included in this PR (reverted). A Linux guest showed only ~1% impact earlier because fio there drove ~75k IOPS — below the contention point where the penalty appears.
Takeaway
Encryption-at-rest here is effectively free for boot, sequential, and write workloads, but costs roughly half the IOPS on high-queue-depth small random reads on low-vCPU guests. This is expected dm-crypt behavior and is documented here rather than tuned away. Guests that need maximum small-random-read IOPS should be sized with more vCPUs or use a non-encrypted StorageClass.
Three follow-up fixes to the encryption-at-rest feature, found while running the storage-validator against an encrypted dm-thin StorageClass: - LUKS resize passphrase: `cryptsetup resize` re-derives the volume key from a keyslot when the key is not reachable in the node plugin's kernel keyring, so it blocked on an interactive prompt during NodeExpandVolume. Feed the passphrase on stdin via `--key-file -` (never on argv). This requires the StorageClass to wire a node-expand-secret so external-resizer populates the request secrets; a clear InvalidArgument is returned when it is missing. - LUKS2 header capacity: the default LUKS2 data offset reserves 16 MiB ahead of the payload, so the dm-crypt mapper exposes 16 MiB less than its backing LV. Grow the backing LV by that overhead at CreateVolume and NodeExpandVolume so the requested capacity is honored end-to-end (exact-fit consumers such as CDI/KubeVirt image imports require it). The reported (usable) CapacityBytes is unchanged. - Secret logging: redact NodeExpandVolume request secrets with protosanitizer.StripSecrets so the passphrase is not written to logs. Signed-off-by: Alejandro Bonilla <abonilla@suse.com>
|
Pushed a follow-up fix commit (
Validated end-to-end: a full storage-validator run against an encrypted LVM CSI StorageClass on single-node Harvester v1.8.1 passed all checks (create/use, offline expand, snapshot, image, VM boot, hotplug). The validator needed RWO/single-node support for this, sent separately as harvester/storage-validator#61. |
What this adds
Opt-in encryption at rest for
dm-thinLVM volumes using LUKS2 / dm-crypt.Encryption is declared on the StorageClass (
encrypted: "true") and follows the exactsame CSI-secret +
CRYPTO_KEY_*convention Longhorn uses, so the existing Harvesterwebhook and dashboard accept it unchanged:
CRYPTO_KEY_VALUECRYPTO_KEY_CIPHERaes-xts-plain64CRYPTO_KEY_HASHsha256CRYPTO_KEY_SIZE256CRYPTO_PBKDFargon2iEach volume is LUKS2-formatted and opened as a dm-crypt device
(
/dev/mapper/csi-lvm-<volID>) atNodePublishVolume; mount / bind-mount / expand alloperate through the mapper. The passphrase is passed to
cryptsetupover stdin, nevervia argv. Unpublish closes the mapper; expand resizes the LUKS device first.
RBAC design question for @WebberHuang1118
The
external-provisionersidecar (controller SA) must resolve thecsi.storage.k8s.io/provisioner-secret-name/-namespacereferenced by an encrypted SC.Because the recommended SC uses
${pvc.namespace}templating, the secret can live inany namespace — so I added cluster-wide
secrets: get/list/watchto thecontroller
ClusterRole(this mirrors what Longhorn's chart grants).Questions:
external-provisioner pattern), or would you prefer to constrain encryption to a
fixed secret namespace with a namespaced
Role(loses per-namespace keyisolation and diverges from the Harvester UI's
${pvc.namespace}convention)?node-publish-secretisdelivered to it by kubelet, not fetched via the API. Flagging in case you'd rather be
explicit about it.
QA — validated end-to-end on a live Harvester v1.8.1 cluster
LUKSmagic +luksDump= LUKS2 / aes-xts-plain64; dm-crypt mapper activecloneStrategy=copy(set via SC annotation in the example)Benchmark (fio, same node, encrypted vs plain — AES-NI)
Overhead is within run-to-run noise on this hardware (AES-NI). Encryption is a per-SC
opt-in, so unencrypted volumes are unaffected.
Notes for reviewers
path, which
luksOpens the device — so an exported copy is readable plaintext atexport time (expected; K10 re-encrypts at its own rest layer). Flagging as a nuance,
not a defect.
the LVM StorageClass form (the only UI touchpoint that is provisioner-specific; images,
volumes, VMs and restore already inherit encryption via the SC).
Test plan
CRYPTO_KEY_*parsing +cryptsetupargument construction (pkg/lvm/encryption_test.go);gofmtclean.Related