Skip to content

storage: persistent per-volume LUKS2 encryption (#1293) - #871

Open
rishi-jat wants to merge 8 commits into
QubesOS:mainfrom
rishi-jat:main
Open

rishi-jat wants to merge 8 commits into
QubesOS:mainfrom
rishi-jat:main

Conversation

@rishi-jat

@rishi-jat rishi-jat commented Aug 15, 2026

Copy link
Copy Markdown

Summary

First slice of QubesOS/qubes-issues#1293: persistent per-volume encryption on the existing storage stack (same approach as ephemeral volatile encryption in #396).

Behavior

  • Volume.encrypted on save_on_stop + rw + dom0 volumes (private on AppVM/Standalone, root on Template/Standalone). Mutually exclusive with ephemeral.
  • LUKS2 (luksFormat / open --type=luks2). Passphrase stays in qubesd memory only (admin.vm.volume.SetPassphrase); not XML, not volume.Info.
  • encrypted= is visible in admin.vm.volume.Info so start tools know to prompt first.
  • Enable: empty volume → luksFormat; volume with data → grow 32 MiB + reencrypt --encrypt. Dirty volumes and volumes with revisions are refused.
  • Start unlocks, stop closes. Failed open/resize after vol.start() rolls back vol.stop() and keeps the passphrase for retry.
  • Clone copies the LUKS container raw (same passphrase).
  • admin.vm.volume.ChangePassphrase payload is old\nnew.
  • Disabling encryption is not implemented (explicit error).
  • Snapshot consumers (AppVM from encrypted template root, DispVM from encrypted AppVM private) are refused.

Out of scope

  • Decrypt / disable
  • Encrypting a source that already has snap-on-start children
  • Automatic revision cleanup before encryption
  • Live cryptsetup integration tests on CI

Test plan

./run-tests qubes.tests.storage_encrypt qubes.tests.api_admin \
  qubes.tests.storage qubes.tests.storage_file qubes.tests.storage_lvm

On a real system: enable on a new private volume, start/stop, wrong passphrase then retry, clone, refuse encrypt on a template that already has AppVMs.

Copilot AI lite review requested due to automatic review settings August 15, 2026 10:41

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@rishi-jat
rishi-jat force-pushed the main branch 4 times, most recently from f62894b to 0b12dbd Compare August 15, 2026 11:18
Add Volume.encrypted for save_on_stop, writable, dom0-backed volumes
(mutex with ephemeral). Format/unlock with LUKS2 and an in-memory
passphrase passed to cryptsetup on stdin; never write the passphrase
to disk or qubes.xml.

luksFormat on empty volumes; in-place reencrypt for existing data.
Refuse dirty volumes and volumes with revisions. Unlock on start,
close on stop; roll back vol.start() if open/resize fails so a wrong
passphrase can be retried. Clone copies the LUKS container raw.

QubesOS/qubes-issues#1293
Expose encrypted in admin.vm.volume.Info. Add Set.encrypted,
SetPassphrase (memory only), and ChangePassphrase (old\nnew).
Disabling encryption is rejected. Refuse encrypting a snapshot
source. Register the new calls in the dom0 spec.

QubesOS/qubes-issues#1293
Unit tests for property constraints, passphrase handling, dirty/revision
refusal, start rollback after failed open, import/clone rules, and the
new Admin API methods. Document Volume.encrypted.

QubesOS/qubes-issues#1293
@codecov

codecov Bot commented Aug 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.00000% with 72 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.14%. Comparing base (e231e51) to head (a85fa0e).
⚠️ Report is 61 commits behind head on main.

Files with missing lines Patch % Lines
qubes/storage/__init__.py 81.78% 45 Missing ⚠️
qubes/utils.py 42.85% 16 Missing ⚠️
qubes/storage/callback.py 50.00% 5 Missing ⚠️
qubes/api/admin.py 92.30% 4 Missing ⚠️
qubes/storage/kernels.py 83.33% 1 Missing ⚠️
qubes/storage/reflink.py 83.33% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #871      +/-   ##
==========================================
+ Coverage   70.51%   71.14%   +0.63%     
==========================================
  Files          61       61              
  Lines       14371    14799     +428     
==========================================
+ Hits        10133    10529     +396     
- Misses       4238     4270      +32     
Flag Coverage Δ
unittests 71.14% <80.00%> (+0.63%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@rishi-jat

Copy link
Copy Markdown
Author

CC @marmarek

@marmarek marmarek left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work :)

Besides the comments below, this could also use an integration test, see qubes/tests/integ/storage.py. It doesn't need to be too detailed, but should cover basic cases:

  • enabling encryption
  • starting / stopping volume (and checking if data properly survives)
  • maybe also encrypting volume with existing data (and again, checking if data is intact)

test_003_snapshot might be a good inspiration.

Comment thread qubes/storage/__init__.py
Comment thread qubes/storage/__init__.py Outdated
Comment thread qubes/storage/__init__.py Outdated
Comment thread qubes/storage/__init__.py
Comment thread qubes/storage/__init__.py
Comment thread qubes/api/admin.py Outdated
Comment thread qubes/storage/__init__.py
@rishi-jat
rishi-jat requested a review from marmarek August 24, 2026 22:15
@rishi-jat

Copy link
Copy Markdown
Author

CC @marmarek

@marmarek

marmarek commented Sep 3, 2026

Copy link
Copy Markdown
Member

Black complains about one line in tests

@marmarek

marmarek commented Sep 3, 2026

Copy link
Copy Markdown
Member

openQArun MACHINE=64bit TEST=system_tests_gui_interactive,system_tests_basic_vm_qrexec_gui,system_tests_pvgrub_salt_storage,system_tests_gui_interactive,system_tests_gui_tools,system_tests_backup

@qubesos-bot

qubesos-bot commented Sep 3, 2026

Copy link
Copy Markdown

OpenQA test summary

Complete test suite and dependencies: https://openqa.qubes-os.org/tests/overview?distri=qubesos&version=4.3&build=202609121440-devel&flavor=pull-requests

Test run included the following:

New failures, excluding unstable

Compared to: https://openqa.qubes-os.org/tests/overview?distri=qubesos&version=4.3&build=2026050504-devel&flavor=update

  • system_tests_pvgrub_salt_storage
    • StorageFile: test_010_persistent_luks (error)
      subprocess.CalledProcessError: Command 'echo test123 | cat - /dev/z...

    • StorageLVM: test_010_persistent_luks (error)
      subprocess.CalledProcessError: Command 'echo test123 | cat - /dev/z...

    • StorageReflinkOnBtrfs: test_010_persistent_luks (error)
      subprocess.CalledProcessError: Command 'echo test123 | cat - /dev/z...

    • StorageReflinkOnExt4: test_010_persistent_luks (error)
      subprocess.CalledProcessError: Command 'echo test123 | cat - /dev/z...

Failed tests

4 failures
  • system_tests_pvgrub_salt_storage
    • StorageFile: test_010_persistent_luks (error)
      subprocess.CalledProcessError: Command 'echo test123 | cat - /dev/z...

    • StorageLVM: test_010_persistent_luks (error)
      subprocess.CalledProcessError: Command 'echo test123 | cat - /dev/z...

    • StorageReflinkOnBtrfs: test_010_persistent_luks (error)
      subprocess.CalledProcessError: Command 'echo test123 | cat - /dev/z...

    • StorageReflinkOnExt4: test_010_persistent_luks (error)
      subprocess.CalledProcessError: Command 'echo test123 | cat - /dev/z...

Fixed failures

Compared to: https://openqa.qubes-os.org/tests/176874#dependencies

5 fixed
  • system_tests_pvgrub_salt_storage
    • TC_41_HVMGrub_debian-13-xfce: test_000_standalone_vm (error)
      qubes.exc.QubesVMError: Cannot connect to qrexec agent for 120 seco...

    • TC_41_HVMGrub_debian-13-xfce: test_001_standalone_vm_dracut (error)
      qubes.exc.QubesVMError: Cannot connect to qrexec agent for 120 seco...

    • TC_41_HVMGrub_debian-13-xfce: test_010_template_based_vm (error)
      qubes.exc.QubesVMError: Cannot connect to qrexec agent for 120 seco...

    • TC_41_HVMGrub_debian-13-xfce: test_011_template_based_vm_dracut (error)
      qubes.exc.QubesVMError: Cannot connect to qrexec agent for 120 seco...

    • TC_41_HVMGrub_fedora-43-xfce: test_010_template_based_vm (error)
      qubes.exc.QubesVMError: Cannot connect to qrexec agent for 120 seco...

Unstable tests

Details
  • system_tests_basic_vm_qrexec_gui

    system_tests/Fail (1/5 times with errors)
    • job 195163 Tests qubes.tests.integ.basic failed (exit code 1), details reporte...
    system_tests/Failed (1/5 times with errors)
    • job 195163 # Test died: Some tests failed at qubesos/tests/system_tests.pm lin...
    TC_30_Gui_daemon/test_002_clipboard_300k (1/5 times with errors)
    • job 195163 : Clipboard copy operation failed - content...
  • system_tests_pvgrub_salt_storage

    system_tests/Failed (1/5 times with errors)
    • job 195016 # Test died: command 'curl --form upload=@nose2-junit.xml --form up...

Performance Tests

Performance degradation:

No issues

Remaining performance tests:

No remaining performance tests

@marmarek

marmarek commented Sep 3, 2026

Copy link
Copy Markdown
Member

system_tests_pvgrub_salt_storage

* StorageFile: [test_010_persistent_luks](https://openqa.qubes-os.org/tests/195034#step/StorageFile/9) (error)
  `qubes.exc.StoragePoolException: Cannot encrypt a volume that has re...`

* StorageLVM: [test_010_persistent_luks](https://openqa.qubes-os.org/tests/195034#step/StorageLVM/9) (error)
  `qubes.exc.StoragePoolException: Cannot encrypt a volume that has re...`

* StorageReflinkOnBtrfs: [test_010_persistent_luks](https://openqa.qubes-os.org/tests/195034#step/StorageReflinkOnBtrfs/9) (error)
  `qubes.exc.StoragePoolException: Cannot set up LUKS: device '/var/tm...`

* StorageReflinkOnExt4: [test_010_persistent_luks](https://openqa.qubes-os.org/tests/195034#step/StorageReflinkOnExt4/9) (error)
  `qubes.exc.StoragePoolException: Cannot set up LUKS: device '/var/tm...`

This is definitely related to this PR. I'm not 100% sure if this is more about issue in tests, or implementation, but needs some fixes....

Keep no revisions in the integ test so File/LVM can encrypt after
the first shutdown. Use the committed reflink image when the dirty
file is gone, and re-resolve the backend path after resize.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

It introduces security-sensitive passphrase handling and LUKS path validation where the current implementation leaves avoidable in-memory passphrase copies and uses assert for runtime checks that can be disabled.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (2)

qubes/storage/init.py:616

  • stop_luks() also relies on an assert for runtime validation of the mapper path. Asserts may be disabled, so this should be a normal conditional check that raises a regular exception.
        assert name.startswith("/dev/mapper/"), (
            "invalid encrypted volume path %r" % name
        )

doc/qubes-storage.rst:169

  • This change removes the existing "Module contents" / .. automodule:: qubes.storage section at the end of the page, while other module docs in doc/ keep it. Unless this was intentionally moved elsewhere, keeping the automodule section helps ensure the public API is documented consistently.
See specific methods documentation for details.

  • Files reviewed: 14/14 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread qubes/storage/__init__.py
Comment on lines +549 to +553
assert name.startswith("/dev/mapper/"), (
"Invalid path %r passed to cryptsetup" % name
)
mapper_name = name[12:]
assert "/" not in mapper_name, "Invalid name passed to cryptsetup"
Comment thread qubes/utils.py
Comment on lines +348 to +352
if isinstance(passphrase, bytearray):
return bytes(passphrase)
if isinstance(passphrase, str):
return passphrase.encode("utf-8")
return passphrase
Comment thread qubes/storage/__init__.py Outdated
candidates = []
if self.path:
candidates.append(self.path)
clean = getattr(self, "_path_clean", None)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accessing driver-specific field here is a layering violation. I'm not sure why reflink driver behaves this way, might be a bug (@rustybird can you help?). But if that's intentional, better override the luks_backend_path function in the reflink driver with driver-specific logic (and not mark it as private anymore).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that’s intentional in Reflink. Fixed by moving the clean/dirty path handling into the public luks_backend_path() override.

Keep generic path/block-device lookup on Volume. Reflink overrides it
so the base class does not read _path_clean.
@marmarek

Copy link
Copy Markdown
Member

openQArun MACHINE=64bit TEST=system_tests_gui_interactive,system_tests_basic_vm_qrexec_gui,system_tests_pvgrub_salt_storage,system_tests_gui_interactive,system_tests_gui_tools,system_tests_backup

@marmarek

Copy link
Copy Markdown
Member
  • system_tests_pvgrub_salt_storage

    * StorageFile: [test_010_persistent_luks](https://openqa.qubes-os.org/tests/195973#step/StorageFile/9) (error)
      `qubes.exc.StoragePoolException: Failed to resize unlocked volume ap...`
    * StorageLVM: [test_010_persistent_luks](https://openqa.qubes-os.org/tests/195973#step/StorageLVM/9) (error)
      `qubes.exc.StoragePoolException: Failed to resize unlocked volume qu...`
    * StorageReflinkOnBtrfs: [test_010_persistent_luks](https://openqa.qubes-os.org/tests/195973#step/StorageReflinkOnBtrfs/9) (error)
      `qubes.exc.StoragePoolException: Failed to resize unlocked volume ap...`
    * StorageReflinkOnExt4: [test_010_persistent_luks](https://openqa.qubes-os.org/tests/195973#step/StorageReflinkOnExt4/9) (error)
      `qubes.exc.StoragePoolException: Failed to resize unlocked volume ap...`
    

Now it fails this way

LUKS2 resize needs the volume key; without it start_luks failed on
every backend after unlock.
@rishi-jat

Copy link
Copy Markdown
Author
  • system_tests_pvgrub_salt_storage
    * StorageFile: [test_010_persistent_luks](https://openqa.qubes-os.org/tests/195973#step/StorageFile/9) (error)
      `qubes.exc.StoragePoolException: Failed to resize unlocked volume ap...`
    * StorageLVM: [test_010_persistent_luks](https://openqa.qubes-os.org/tests/195973#step/StorageLVM/9) (error)
      `qubes.exc.StoragePoolException: Failed to resize unlocked volume qu...`
    * StorageReflinkOnBtrfs: [test_010_persistent_luks](https://openqa.qubes-os.org/tests/195973#step/StorageReflinkOnBtrfs/9) (error)
      `qubes.exc.StoragePoolException: Failed to resize unlocked volume ap...`
    * StorageReflinkOnExt4: [test_010_persistent_luks](https://openqa.qubes-os.org/tests/195973#step/StorageReflinkOnExt4/9) (error)
      `qubes.exc.StoragePoolException: Failed to resize unlocked volume ap...`
    

Now it fails this way

@marmarek Could you please rerun system_tests_pvgrub_salt_storage and check test_010_persistent_luks with the latest fix in 11b7064?

@marmarek

Copy link
Copy Markdown
Member

openQArun MACHINE=64bit TEST=system_tests_basic_vm_qrexec_gui,system_tests_pvgrub_salt_storage,system_tests_backup

@marmarek

Copy link
Copy Markdown
Member
  • StorageFile: test_010_persistent_luks (error)
    subprocess.CalledProcessError: Command 'echo test123 | cat - /dev/z...

  • StorageLVM: test_010_persistent_luks (error)
    subprocess.CalledProcessError: Command 'echo test123 | cat - /dev/z...

  • StorageReflinkOnBtrfs: test_010_persistent_luks (error)
    subprocess.CalledProcessError: Command 'echo test123 | cat - /dev/z...

  • StorageReflinkOnExt4: test_010_persistent_luks (error)
    subprocess.CalledProcessError: Command 'echo test123 | cat - /dev/z...

Now the test says data doesn't survive encrypting. I have not checked what actually happens (is it empty? scrambled? something else?)

A bare cryptsetup resize after in-place encrypt grows the guest
disk by 16M and breaks the persist check.
@rishi-jat

rishi-jat commented Sep 15, 2026

Copy link
Copy Markdown
Author
  • StorageFile: test_010_persistent_luks (error)
    subprocess.CalledProcessError: Command 'echo test123 | cat - /dev/z...
  • StorageLVM: test_010_persistent_luks (error)
    subprocess.CalledProcessError: Command 'echo test123 | cat - /dev/z...
  • StorageReflinkOnBtrfs: test_010_persistent_luks (error)
    subprocess.CalledProcessError: Command 'echo test123 | cat - /dev/z...
  • StorageReflinkOnExt4: test_010_persistent_luks (error)
    subprocess.CalledProcessError: Command 'echo test123 | cat - /dev/z...

Now the test says data doesn't survive encrypting. I have not checked what actually happens (is it empty? scrambled? something else?)
I investigated the latest test_010_persistent_luks failure.

@marmarek , I want to confirm the expected device size after in-place LUKS2 re-encryption.

If the original VM volume is 128 MiB and we temporarily grow it by 32 MiB for --reduce-device-size 32M, after re-encryption completes, what size should cryptsetup open expose to the guest?

Should it be:
128 MiB, preserving the original VM disk size, or
144 MiB, because the LUKS2 data segment starts 16 MiB after the beginning of the backing device?

If the expected guest size is 128 MiB, should we shrink the unused tail after re-encryption, or is there a preferred way to make cryptsetup open expose exactly the original 128 MiB?

I’m asking because OpenQA compares the whole /dev/xvde against the original 128 MiB image, so exposing 144 MiB causes the persistence test to fail even though the original data itself is intact.

@marmarek

Copy link
Copy Markdown
Member

I'd say encryption should be transparent for the VM, meaning the size should not change. So, 128M in this example.
IIUC extending by 32M (instead of 16M) is to have extra margin in case the header will need more space in the future. This should be fine. But then, IMO data should start at 32M offset. Looks like there is --offset option for that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants