Both key bag readers in libfsapfs decrypt unconditionally. libfsapfs_container_key_bag_read_file_io_handle always AES-128-XTS decrypts the container key bag using the container UUID as both key and tweak key, and libfsapfs_volume_key_bag_read_file_io_handle does the same with the volume UUID. Neither has a branch that can skip the decrypt, and in each function read_data is called only on the decrypted buffer. On images where the key bag is already stored in the clear, and those exist, the decrypt turns a valid checksum-correct object into noise, and the parse then fails. On the container key bag that failure is recorded as an encryption property of the image rather than as a read error; on the volume key bag it is fatal and aborts the open.
All line numbers below are against c5afe4d, current main at the time of writing.
The symptom, and why it reads as something other than a parse failure
On the container side the parse failure is converted into a flag and then printed as a finding:
libfsapfs_container_key_bag.c:504 object type != 0x6b657973 -> return( 0 )
libfsapfs_container.c:1791 result == 0 -> key_bag->is_locked = 1
libfsapfs_container.c:2090 libfsapfs_container_is_locked() returns that flag
fsapfstools/info_handle.c:4365 prints "Is locked (uses hardware encryption)"
That string is the object-type mismatch restated. It is not a reading of any on-disk flag: libfsapfs_container.c:1791 is the only assignment in the tree that makes key_bag->is_locked non-zero, and the struct is zeroed at libfsapfs_container_key_bag.c:89.
The volume line printed under it is not independent either. At libfsapfs_volume.c:557, for a volume not marked unencrypted on disk, the volume inherits the container key bag's flag before its own volume key bag is read, so a single container-level parse failure surfaces at two levels and reads like corroboration:
libfsapfs_volume.c:557 internal_volume->is_locked = internal_volume->container_key_bag->is_locked;
fsapfstools/info_handle.c:4100 prints "Is locked"
On a specimen where the container key bag really is encrypted, that same volume line comes from the genuine path at libfsapfs_volume.c:640, after the volume key bag has been read successfully. Same string, two different causes, and nothing in the output distinguishes them.
The same symptom, from an image that is not corrupt
documentation/Apple File System (APFS).asciidoc carries this under == Corruption scenarios:
=== Container key bag is hardware encrypted but volume is not encrypted
Seen in APFS containers created by certain digital forensics tools. The
container key bag is either hardware encrypted or contains random data but
the volume is not encrypted.
I hit exactly that symptom on an image nothing had corrupted, and the raw block is neither hardware encrypted nor random. It is an intact, well-formed plaintext container key bag carrying a Fletcher-64 checksum that verifies over its own contents. The scenario above is written for an unencrypted volume, and the volume on this image is in fact encrypted, so the specimen matches neither half of it, yet libfsapfs destroys the block by decrypting it and then prints the diagnostic your notes file under that corruption heading.
So the library produces that same container diagnostic for an image whose container key bag is valid plaintext, and the two can be told apart cheaply. Random data agreeing with a stored 64-bit checksum of itself is a 2^-64 event. The three fields the container reader already reads are therefore enough to separate the cases before deciding whether to decrypt: object type, object subtype, and the checksum. If the raw on-disk block passes all three it is intact plaintext, and decrypting it is the thing that breaks it. If it does not pass, the current path is correct and nothing changes.
I am not suggesting the forensics-tool observation is wrong. The narrower point is that libfsapfs produces the identical output for an image that matches neither half of that scenario, and cannot presently tell the two apart because it never inspects the raw block. A reader who sees that string is pointed at a corruption heading, and on this specimen nothing is corrupt. That says nothing about the images the documented scenario was written from; it is only that the same diagnostic now covers a non-corrupt case too.
Site 1, the container key bag
libfsapfs_container_key_bag_read_file_io_handle at libfsapfs_container_key_bag.c:183 reads data_size bytes, initialises AES-128-XTS at :304, sets key and tweak key to the container identifier at :316, decrypts at :333 with the tweak start derived from the file offset, and calls read_data on the decrypted buffer at :383. That is the only call site of libfsapfs_container_key_bag_read_data, so there is no path that ever parses the block as read.
Measured on a macOS 14.8.7 FileVault guest, blocks pulled off the disk image with dd, no attach and no boot:
| container key bag block |
object type at +24 |
subtype |
stored Fletcher-64 |
computed |
verdict |
| macOS 14.8.7, transaction 3384 |
0x6b657973 ("keys") |
0 |
0x754b431577e3645a |
identical |
plaintext |
| macOS 14.8.7, transaction 320 |
0x6b657973 ("keys") |
0 |
0xab0fbc3104475b86 |
identical |
plaintext |
| macOS 15.7.7, raw block |
0xa966db36 |
n/a |
0x7ede6fefcbc49884 |
0xa0b2e5bec81a94f9 |
encrypted |
| macOS 15.7.7, after UUID decrypt |
0x6b657973 ("keys") |
0 |
0x5a227d649f300ed6 |
identical |
control |
The 15.7.7 rows are the negative control, same instrument, same class of block, opposite verdict: its raw block fails on object type and its raw checksum does not verify, and after the container-UUID decrypt it becomes a valid keys object whose checksum does verify. That is what makes the 14.8.7 reading a property of the specimen rather than an artifact of the test. As a mutation control, flipping a single bit in the verifying 14.8.7 block makes the checksum test reject it, so the test discriminates rather than returning a constant.
Site 2, the volume key bag
The same anti-pattern sits one layer down in a sibling function. libfsapfs_volume_key_bag_read_file_io_handle at libfsapfs_volume_key_bag.c:182 initialises AES-128-XTS at :302, sets key and tweak key to the volume identifier at :314, decrypts at :331, and calls read_data on the decrypted buffer at :381, again the only call site.
Two differences from the container site, both of which leave the volume reader with less to work with:
libfsapfs_volume_key_bag_read_data does not verify Fletcher-64 at all. It reads the stored checksum only inside the HAVE_DEBUG_OUTPUT block at :522, to print it. So this reader has strictly less evidence available to it than the container reader does, which verifies the checksum at libfsapfs_container_key_bag.c:572.
- The object-type mismatch at
:494 does goto on_error and returns -1 rather than 0, and the caller at libfsapfs_volume.c:606 treats any non-1 return as fatal and jumps to on_error at :624, so the volume open aborts rather than degrading to a flag.
Measured offline and read-only on the same 14.8.7 guest, on volume 5, the FileVault Data volume:
| volume key bag block |
raw type at +24 |
raw Fletcher-64 |
after volume-UUID decrypt |
| macOS 14.8.7, volume 5 |
0x72656373 ("recs"), subtype 0 |
matches, 0x031b29a9c4fbcd55 |
type 0x44a5bcb7, checksum fails |
macOS 15.7.7, Data volume |
0x54c6c28b |
does not match |
type 0x72656373 ("recs"), matches 0x33415d62c4eeea4c |
A mirror image of the container table, and libfsapfs agrees with it: calling libfsapfs's own libfsapfs_volume_key_bag_read_data directly against the built library returns 1 on the 14.8.7 raw block and -1 on the decrypted one. The raw block is a complete volume key bag, not just a plausible header: format version 2, two entries, data size 400, two 162-byte type-3 KEK records, with the canonical APFS personal-recovery-key UUID ebc6c064-0000-11aa-aa11-00306543ecac in one of them. It is the same shape as the 15.7.7 volume key bag after that one is decrypted.
0x44a5bcb7 is not an object type. It is the XTS image of that block's plaintext recs type field under the volume UUID, which I reproduced byte for byte using libfsapfs's own encryption context, so the second site is the same story as the first rather than a separate puzzle.
One reason this is awkward to see from the outside: every debug dump is post-decrypt. libfsapfs_container_key_bag.c:375 labels the decrypted buffer unencrypted container key bag data, :488 dumps the object header from that same decrypted buffer, and libfsapfs_volume_key_bag.c:373 and :482 are the equivalents. Running with -v therefore never shows the bytes as they are on disk, which is exactly the information needed to notice this.
A read-only probe is enough, and apfs-fuse already does it this way
apfs-fuse checks the raw key-bag object type before decrypting, inside KeyManager::LoadKeybag, in commit b595551 of 2023-03-11, whose message is Add ability to mount Apple M2 VM images:
m_container.ReadBlocks(data.data(), block, blockcnt);
-
- DecryptBlocks(data.data(), block, blockcnt, uuid);
+ if (mk->mk_obj.o_type == type)
+ m_is_unencrypted = true;
+ else
+ DecryptBlocks(data.data(), block, blockcnt, uuid);
Worth noting for the two-sites point: LoadKeybag there is a single shared function, called with the container key bag type 0x6b657973 for the container bag and with APFS_VOL_KEYBAG_OBJ (0x72656373) for volume bags, so that one check covers both. libfsapfs has the two readers written out separately, which is why the same defect has to be fixed twice here.
For libfsapfs I would not use read_data itself as the probe. It appends entries to the bag as it goes (libfsapfs_container_key_bag.c:680) and its error cleanup at :748 frees only the current entry and header without rolling previous appends back, so a try-raw-then-fall-back-to-decrypted design can leave partial state behind and compound it on the second parse. A read-only predicate over the buffer avoids that by construction:
is_plaintext_key_bag( data, data_size ):
data_size >= sizeof( fsapfs_object_t )
&& object type at offset 24 == 0x6b657973 /* 0x72656373 for a volume key bag */
&& object subtype at offset 28 == 0x00000000
&& fletcher64( data + 8, data_size - 8 ) == stored checksum at offset 0
It touches no libfsapfs object, so a negative result leaves nothing to free, and read_data is then called exactly once on whichever buffer the predicate selected. It also needs nothing new: the type and subtype tests are the ones read_data already performs, and libfsapfs_checksum_calculate_fletcher64 is already used by the container superblock, volume superblock, checkpoint map and container key bag readers.
The obvious caveat is that Fletcher-64 checks consistency and not authenticity, so on a hostile image it is not a security boundary. The structural validation in read_data still does that work, and nothing here weakens it.
What I have built, and what I have not
I have a patch for the container site, and it is compiled and run rather than only reviewed. It adds the predicate above as libfsapfs_container_key_bag_data_is_unencrypted, and read_file_io_handle then either takes ownership of the buffer as read or allocates and decrypts as before; the single read_data call is untouched.
| image |
stock fsapfsinfo |
patched fsapfsinfo |
| macOS 14.8.7, container key bag plaintext |
prints Is locked (uses hardware encryption), key bag never parses, yet 6 volumes print and the run exits 0 with empty stderr |
that line is gone, the container key bag parses, then the run stops at the volume key bag: 4 volumes print, exit 1 |
| macOS 15.7.7, container key bag encrypted |
parses, 6 volumes, exit 0 |
stdout and stderr byte-identical to stock |
Stock there is the patch's parent commit rather than upstream main, so the table isolates this one change. On the encrypted image the predicate rejects the raw block on object type alone and falls through to the unchanged decrypt path, so that case is literally unaltered.
The exit 0 on the stock plaintext run is the defect succeeding quietly rather than the tool working: because the container key bag never parses, its entries array stays empty, the volume key bag extent is never found, and the volume key bags are never read at all, so the volumes still open, just without their key bags. The container patch is what first reaches the volume key bag, so on this specimen it moves the run from a clean-but-mislabelled exit 0 to exit 1 at the second, genuinely separate defect. The two sites therefore want fixing together: the container patch is not independently shippable against this image without the volume-side fix.
I have not built a fix for the volume site. The shape carries over unchanged with 0x72656373 in place of 0x6b657973, but I have only measured that site read-only and I would rather say so than imply it is tested. On the patched 14.8.7 run the container key bag now parses and the run proceeds until it stops on exactly that second site:
libfsapfs_volume_key_bag_read_data: invalid object type: 0x44a5bcb7.
libfsapfs_volume_key_bag_read_file_io_handle: unable to read volume key bag.
libfsapfs_internal_volume_open_read: unable to read volume key bag at offset: 22694916096 (0x548b8f000).
Also not done: a regression test in the library's own suite. A plaintext and an encrypted block for each reader, since the container and volume key bag object types differ, would cover both sites with no image involved.
Reproducing it
The specimen is a disposable Apple Virtualization framework guest: install macOS 14 in a VM, enable FileVault, shut down cleanly, then read the block named by the key bag block number in the container superblock straight off the disk image.
# block number and count come from the container superblock; 5540804 and
# container offset 524308480 are this specimen's values
dd if=disk.img bs=4096 skip=$(( (524308480 + 5540804 * 4096) / 4096 )) count=1 of=kb.bin
xxd -l 32 kb.bin
# bytes 24 to 27 read 73 79 65 6b, little endian 0x6b657973, and the
# Fletcher-64 over kb.bin[8:] equals the value stored in kb.bin[0:8]
For context rather than as a claim about libfsapfs: across four guests, two per major version, on one host, one hypervisor, and one image family (the cirruslabs vanilla line), macOS 14 (14.7.7 and 14.8.7) wrote the container key bag in the clear while macOS 15 (15.6.1 and 15.7.7) wrote it encrypted. I have not established a mechanism for that, it is a single-author measurement, and I would not want the issue to rest on it. It matters here only in that the symptom looks deterministic across a class of images rather than being a one-off, which is why a guard seems better value than a note.
What would be most useful to you
I am happy to open a PR with the container patch as it stands, extend the same predicate to the volume key bag, and add a regression test. If you would take test data as raw blocks rather than as an image, a plaintext and an encrypted block for each of the two readers make a self-contained fixture. And if you would rather keep the format-notes entry as a corruption scenario with the valid-plaintext case called out separately from the random-data case, that works too; I mostly want the library to stop destroying a block it could have read.
Both key bag readers in libfsapfs decrypt unconditionally.
libfsapfs_container_key_bag_read_file_io_handlealways AES-128-XTS decrypts the container key bag using the container UUID as both key and tweak key, andlibfsapfs_volume_key_bag_read_file_io_handledoes the same with the volume UUID. Neither has a branch that can skip the decrypt, and in each functionread_datais called only on the decrypted buffer. On images where the key bag is already stored in the clear, and those exist, the decrypt turns a valid checksum-correct object into noise, and the parse then fails. On the container key bag that failure is recorded as an encryption property of the image rather than as a read error; on the volume key bag it is fatal and aborts the open.All line numbers below are against
c5afe4d, currentmainat the time of writing.The symptom, and why it reads as something other than a parse failure
On the container side the parse failure is converted into a flag and then printed as a finding:
That string is the object-type mismatch restated. It is not a reading of any on-disk flag:
libfsapfs_container.c:1791is the only assignment in the tree that makeskey_bag->is_lockednon-zero, and the struct is zeroed atlibfsapfs_container_key_bag.c:89.The volume line printed under it is not independent either. At
libfsapfs_volume.c:557, for a volume not marked unencrypted on disk, the volume inherits the container key bag's flag before its own volume key bag is read, so a single container-level parse failure surfaces at two levels and reads like corroboration:On a specimen where the container key bag really is encrypted, that same volume line comes from the genuine path at
libfsapfs_volume.c:640, after the volume key bag has been read successfully. Same string, two different causes, and nothing in the output distinguishes them.The same symptom, from an image that is not corrupt
documentation/Apple File System (APFS).asciidoccarries this under== Corruption scenarios:I hit exactly that symptom on an image nothing had corrupted, and the raw block is neither hardware encrypted nor random. It is an intact, well-formed plaintext container key bag carrying a Fletcher-64 checksum that verifies over its own contents. The scenario above is written for an unencrypted volume, and the volume on this image is in fact encrypted, so the specimen matches neither half of it, yet libfsapfs destroys the block by decrypting it and then prints the diagnostic your notes file under that corruption heading.
So the library produces that same container diagnostic for an image whose container key bag is valid plaintext, and the two can be told apart cheaply. Random data agreeing with a stored 64-bit checksum of itself is a 2^-64 event. The three fields the container reader already reads are therefore enough to separate the cases before deciding whether to decrypt: object type, object subtype, and the checksum. If the raw on-disk block passes all three it is intact plaintext, and decrypting it is the thing that breaks it. If it does not pass, the current path is correct and nothing changes.
I am not suggesting the forensics-tool observation is wrong. The narrower point is that libfsapfs produces the identical output for an image that matches neither half of that scenario, and cannot presently tell the two apart because it never inspects the raw block. A reader who sees that string is pointed at a corruption heading, and on this specimen nothing is corrupt. That says nothing about the images the documented scenario was written from; it is only that the same diagnostic now covers a non-corrupt case too.
Site 1, the container key bag
libfsapfs_container_key_bag_read_file_io_handleatlibfsapfs_container_key_bag.c:183readsdata_sizebytes, initialises AES-128-XTS at:304, sets key and tweak key to the container identifier at:316, decrypts at:333with the tweak start derived from the file offset, and callsread_dataon the decrypted buffer at:383. That is the only call site oflibfsapfs_container_key_bag_read_data, so there is no path that ever parses the block as read.Measured on a macOS 14.8.7 FileVault guest, blocks pulled off the disk image with
dd, no attach and no boot:0x6b657973("keys")0x754b431577e3645a0x6b657973("keys")0xab0fbc3104475b860xa966db360x7ede6fefcbc498840xa0b2e5bec81a94f90x6b657973("keys")0x5a227d649f300ed6The 15.7.7 rows are the negative control, same instrument, same class of block, opposite verdict: its raw block fails on object type and its raw checksum does not verify, and after the container-UUID decrypt it becomes a valid
keysobject whose checksum does verify. That is what makes the 14.8.7 reading a property of the specimen rather than an artifact of the test. As a mutation control, flipping a single bit in the verifying 14.8.7 block makes the checksum test reject it, so the test discriminates rather than returning a constant.Site 2, the volume key bag
The same anti-pattern sits one layer down in a sibling function.
libfsapfs_volume_key_bag_read_file_io_handleatlibfsapfs_volume_key_bag.c:182initialises AES-128-XTS at:302, sets key and tweak key to the volume identifier at:314, decrypts at:331, and callsread_dataon the decrypted buffer at:381, again the only call site.Two differences from the container site, both of which leave the volume reader with less to work with:
libfsapfs_volume_key_bag_read_datadoes not verify Fletcher-64 at all. It reads the stored checksum only inside theHAVE_DEBUG_OUTPUTblock at:522, to print it. So this reader has strictly less evidence available to it than the container reader does, which verifies the checksum atlibfsapfs_container_key_bag.c:572.:494doesgoto on_errorand returns -1 rather than 0, and the caller atlibfsapfs_volume.c:606treats any non-1 return as fatal and jumps toon_errorat:624, so the volume open aborts rather than degrading to a flag.Measured offline and read-only on the same 14.8.7 guest, on volume 5, the FileVault
Datavolume:0x72656373("recs"), subtype 00x031b29a9c4fbcd550x44a5bcb7, checksum failsDatavolume0x54c6c28b0x72656373("recs"), matches0x33415d62c4eeea4cA mirror image of the container table, and libfsapfs agrees with it: calling libfsapfs's own
libfsapfs_volume_key_bag_read_datadirectly against the built library returns 1 on the 14.8.7 raw block and -1 on the decrypted one. The raw block is a complete volume key bag, not just a plausible header: format version 2, two entries, data size 400, two 162-byte type-3 KEK records, with the canonical APFS personal-recovery-key UUIDebc6c064-0000-11aa-aa11-00306543ecacin one of them. It is the same shape as the 15.7.7 volume key bag after that one is decrypted.0x44a5bcb7is not an object type. It is the XTS image of that block's plaintextrecstype field under the volume UUID, which I reproduced byte for byte using libfsapfs's own encryption context, so the second site is the same story as the first rather than a separate puzzle.One reason this is awkward to see from the outside: every debug dump is post-decrypt.
libfsapfs_container_key_bag.c:375labels the decrypted bufferunencrypted container key bag data,:488dumps the object header from that same decrypted buffer, andlibfsapfs_volume_key_bag.c:373and:482are the equivalents. Running with-vtherefore never shows the bytes as they are on disk, which is exactly the information needed to notice this.A read-only probe is enough, and apfs-fuse already does it this way
apfs-fuse checks the raw key-bag object type before decrypting, inside
KeyManager::LoadKeybag, in commitb595551of 2023-03-11, whose message isAdd ability to mount Apple M2 VM images:Worth noting for the two-sites point:
LoadKeybagthere is a single shared function, called with the container key bag type0x6b657973for the container bag and withAPFS_VOL_KEYBAG_OBJ(0x72656373) for volume bags, so that one check covers both. libfsapfs has the two readers written out separately, which is why the same defect has to be fixed twice here.For libfsapfs I would not use
read_dataitself as the probe. It appends entries to the bag as it goes (libfsapfs_container_key_bag.c:680) and its error cleanup at:748frees only the current entry and header without rolling previous appends back, so a try-raw-then-fall-back-to-decrypted design can leave partial state behind and compound it on the second parse. A read-only predicate over the buffer avoids that by construction:It touches no libfsapfs object, so a negative result leaves nothing to free, and
read_datais then called exactly once on whichever buffer the predicate selected. It also needs nothing new: the type and subtype tests are the onesread_dataalready performs, andlibfsapfs_checksum_calculate_fletcher64is already used by the container superblock, volume superblock, checkpoint map and container key bag readers.The obvious caveat is that Fletcher-64 checks consistency and not authenticity, so on a hostile image it is not a security boundary. The structural validation in
read_datastill does that work, and nothing here weakens it.What I have built, and what I have not
I have a patch for the container site, and it is compiled and run rather than only reviewed. It adds the predicate above as
libfsapfs_container_key_bag_data_is_unencrypted, andread_file_io_handlethen either takes ownership of the buffer as read or allocates and decrypts as before; the singleread_datacall is untouched.fsapfsinfofsapfsinfoIs locked (uses hardware encryption), key bag never parses, yet 6 volumes print and the run exits 0 with empty stderrStock there is the patch's parent commit rather than upstream
main, so the table isolates this one change. On the encrypted image the predicate rejects the raw block on object type alone and falls through to the unchanged decrypt path, so that case is literally unaltered.The exit 0 on the stock plaintext run is the defect succeeding quietly rather than the tool working: because the container key bag never parses, its entries array stays empty, the volume key bag extent is never found, and the volume key bags are never read at all, so the volumes still open, just without their key bags. The container patch is what first reaches the volume key bag, so on this specimen it moves the run from a clean-but-mislabelled exit 0 to exit 1 at the second, genuinely separate defect. The two sites therefore want fixing together: the container patch is not independently shippable against this image without the volume-side fix.
I have not built a fix for the volume site. The shape carries over unchanged with
0x72656373in place of0x6b657973, but I have only measured that site read-only and I would rather say so than imply it is tested. On the patched 14.8.7 run the container key bag now parses and the run proceeds until it stops on exactly that second site:Also not done: a regression test in the library's own suite. A plaintext and an encrypted block for each reader, since the container and volume key bag object types differ, would cover both sites with no image involved.
Reproducing it
The specimen is a disposable Apple Virtualization framework guest: install macOS 14 in a VM, enable FileVault, shut down cleanly, then read the block named by the key bag block number in the container superblock straight off the disk image.
For context rather than as a claim about libfsapfs: across four guests, two per major version, on one host, one hypervisor, and one image family (the cirruslabs vanilla line), macOS 14 (14.7.7 and 14.8.7) wrote the container key bag in the clear while macOS 15 (15.6.1 and 15.7.7) wrote it encrypted. I have not established a mechanism for that, it is a single-author measurement, and I would not want the issue to rest on it. It matters here only in that the symptom looks deterministic across a class of images rather than being a one-off, which is why a guard seems better value than a note.
What would be most useful to you
I am happy to open a PR with the container patch as it stands, extend the same predicate to the volume key bag, and add a regression test. If you would take test data as raw blocks rather than as an image, a plaintext and an encrypted block for each of the two readers make a self-contained fixture. And if you would rather keep the format-notes entry as a corruption scenario with the valid-plaintext case called out separately from the random-data case, that works too; I mostly want the library to stop destroying a block it could have read.