luksext4: Reading LUKS-Encrypted ext4 Drives on macOS
I have a 2 TB backup drive that was written on Linux: LUKS2 encryption, ext4 inside. Plugging it into a Mac gets you the "The disk you inserted was not readable by this computer" dialog, with Initialize… sitting right there next to Ignore — which is not the most desirable default for a backup disk. I could always attach it to a Raspberry Pi 4 I had running at the time, but the Pi USB power envelope is generally too tight for M.2 SSD drives — the supply browns out on anything this power hungry, and getting a powered USB hub just to read a file from a backup was a hassle.
The usual advice is to install a Linux VM and pass the USB device through. On
an Apple Silicon Mac that is an x86-emulation-shaped afternoon, and macFUSE
plus ext4fuse means a kernel extension and the security-approval dance. Both
felt like too much machinery for "read some files".
So instead I did what I had recently done with vdiread and created luksext4, a pure-Python
read-only reader that implements LUKS, LVM and ext4 well enough to get files
off the drive. Nothing is mounted, nothing is installed into the system, and
the device is opened O_RDONLY — it physically cannot damage the drive.
Like the VDI project, this one was also almost single-shot prompted with Opus 4.8, so what follows is mostly an AI-created tour of an AI-created tool. The main takeaway is that these types of tools can now be created basically on demand, since all the components (LUKS, ext4) are open source, and an agent can either reach for existing libraries or, in a pinch, read the relevant kernel modules and adapt the logic to whatever language you want.
What It Reads
The stack turned out to be more layered than I expected, because the drive was LUKS → LVM → ext4 rather than LUKS → ext4:
- LUKS1 and LUKS2 — PBKDF2 and Argon2i/Argon2id key derivation, aes-xts-plain64, aes-cbc-essiv:sha256 and the other common cipher/IV combinations, 512- and 4096-byte encryption sectors, and the anti-forensic keyslot merge.
- LVM2 in between — linear logical volumes on a single physical
volume, which is what a
cryptsetup+lvminstall produces by default. - ext2/ext3/ext4 — extent trees and the classic indirect block maps, 64-bit block numbers, htree directories, fast and slow symlinks, sparse files, inline data, in-inode extended attributes.
- GPT/MBR scanning, so you can point it at a whole disk or a raw image instead of a specific slice.
Not supported, deliberately: writing, journal replay, btrfs/xfs/ZFS, fscrypt, RAID, and thin or striped LVM. The journal one is worth knowing about — if the drive was not cleanly unmounted, the last writes before it was yanked may not be visible. The tool warns when the filesystem state is not clean.
Using It
Plug in the drive, click Ignore on the macOS dialog, and find the partition:
diskutil list # look for a "Linux" or "Linux LVM" partition, e.g. disk4s1
Raw disk access needs root, so everything runs under sudo:
sudo .venv/bin/luksext4 info /dev/rdisk4s1
Here is real output, from a small LUKS2 test image rather than my actual backup drive:
LUKS2 volume
UUID: aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
label: backup
cipher: aes-xts-plain64
sector: 4096
payload at: 1048576 bytes
keyslot 0: argon2id (t=2, m=65536 KiB, p=2)
ext4 filesystem
label: backup
UUID: daa30f4c-bbd3-4e53-828e-87a4903e6da2
block size: 1024
blocks: 49152 (0.05 GB)
inodes: 12288
last write: 2026-08-03 22:11:20
last mounted: (unknown)
state: clean
Then browse and copy:
$ luksext4 ls demo-luks.img / -l
drwxr-xr-x 3 501 0 1024 2026-08-03 22:11 backups
lrwxr-xr-x 1 501 0 18 2026-08-03 22:11 latest -> backups/2026-07-20
drwx------ 2 0 0 12288 2026-08-03 22:11 lost+found
-rw-r--r-- 1 501 0 31 2026-08-03 22:11 notes.txt
$ luksext4 cp demo-luks.img /backups ./out -r
luksext4: copied 1 files, 2 dirs, 0 symlinks, 3145728 bytes
cp preserves mtimes and symlinks and shows progress for large files, and
there is a cat for pushing a single file to stdout. Image files work exactly
like devices, so you can dd the drive first and work from a copy.
The passphrase is resolved from --pass-file, --keyfile, the
LUKS_PASSWORD environment variable or a .env file in the current directory,
and finally an interactive prompt. The .env route is more useful than it
sounds: sudo keeps the working directory, so a project-local (git-ignored)
.env is found without exporting anything into your shell history.
The Symlink Gotcha
This one took a moment to figure out. Backup scripts love absolute symlinks
— a latest link pointing at /mnt/luks/backup-2026-07-20/. But this
tool's filesystem root is /, not /mnt/luks, so every one of those links
pointed into the void.
The fix: ext4's superblock records the last-mounted path. The tool reads that
field, strips the prefix from absolute symlink targets, and the links resolve
correctly. --mountpoint /some/path overrides it, and --mountpoint /
disables the stripping entirely.
Mounting It in Finder Over WebDAV
Command-line copying works, but sometimes you want to browse the drive in Finder, or play a video straight off it. FUSE would do that — and would reintroduce the kernel extension I was trying to avoid.
macOS has a WebDAV client built in, though. So luksext4 serve unlocks the
drive once and exposes it as a read-only WebDAV share on localhost:
sudo .venv/bin/luksext4 serve /dev/rdisk4s1
Serving backup read-only at http://127.0.0.1:8080/
Finder: Go -> Connect to Server -> http://127.0.0.1:8080/
CLI: mkdir -p /tmp/luks && mount_webdav http://127.0.0.1:8080/ /tmp/luks
Then Go → Connect to Server… (⌘K) in Finder and it mounts like any network volume. Byte-range requests are implemented, so seeking in a video does not re-download the file. Every write method (PUT, DELETE, MKCOL and friends) is answered with 403.
This fell out of the architecture almost for free. Everything in the project is
built on one tiny interface — an object with read_at(offset, length)
and a size — and the layers just stack:
BlockDevice / SubView blockio.py aligned, cached raw reads
└─ CryptView crypto.py per-sector AES-XTS/CBC decrypt
└─ LvView lvm.py LVM linear segment remapping
└─ Ext4 ext4.py filesystem: inodes, dirs, files
The WebDAV front-end only ever talks to the Ext4 object. It has no idea
whether there is encryption or LVM underneath.
One warning: the server binds to 127.0.0.1 only, and it should stay that way.
The connection carries decrypted data over plain HTTP, so never pass
--host 0.0.0.0. If others share the Mac, --user NAME adds WebDAV
authentication.
Is It Fast Enough?
Surprisingly, yes. On Apple Silicon, decryption runs at roughly 100 MB/s with
512-byte LUKS sectors and about 640 MB/s with 4096-byte sectors — the
cryptography package is OpenSSL-backed, so the AES itself is hardware
accelerated and the per-sector Python overhead dominates. That is well past USB
enclosure speeds for the 4K case.
The raw character device /dev/rdiskNsM is noticeably faster than the buffered
/dev/diskNsM, and reads are 4K-aligned and LRU-cached internally.
Testing Something Like This
Writing a filesystem reader is easy; writing a correct one is not, and the failure mode is silently wrong bytes. So the test suite tries hard to compare against independent implementations:
- ext4 images are built by real
mke2fsin five feature variants (4K and 1K blocks, 64-bit, ext3-style block maps, inline data), then read back and compared bit-for-bit. - LUKS containers are round-tripped through an in-tree writer that uses the forward transforms, covering LUKS1 and LUKS2, PBKDF2 and Argon2i/id, XTS and CBC-ESSIV, and both sector sizes.
- LVM mapping is checked with a hand-built PV that stores an LV's extents out of order, so a naive sequential mapping would fail.
- The WebDAV server is tested for PROPFIND listings, ranged GETs, non-ASCII filenames, and that every write method is refused.
Plus the real end-to-end check: it read my actual 2 TB LUKS2 backup drive.
Requires Python 3.9+ and uv:
git clone https://github.com/jokkebk/luksext4.git
cd luksext4
uv sync
sudo .venv/bin/luksext4 info /dev/rdisk4s1
Source: github.com/jokkebk/luksext4