Parse macOS 26 FileVault keybags: 22-byte KEK metadata, expanded container VEK entry, and big-endian DER long-form lengths - #89
Conversation
|
Can you describe the format case that cannot be read? Let's make sure it is also captured in https://github.com/dfirlabs/apfs-specimens |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #89 +/- ##
==========================================
+ Coverage 28.05% 29.09% +1.03%
==========================================
Files 73 73
Lines 15918 15907 -11
Branches 3659 3671 +12
==========================================
+ Hits 4466 4628 +162
+ Misses 10281 10093 -188
- Partials 1171 1186 +15 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Thanks for looking at this. Answering the format question first, then the The format caseTwo independent differences from the layout libfsapfs models. Only the first 1.
2. The container key bag VEK entry is 388 bytes, not 122. Outer
apfs-fuse does not model this entry either — I have labelled the outer Bounds checking that this change makes necessaryAccepting With a guard page immediately after the record, current
If you would rather keep these separate from the format change I am happy to On capturing it in dfirlabs/apfs-specimensI tried to reproduce it the way
So the trigger is not the macOS version — it is a real FileVault-enabled I am happy to do whichever you prefer:
I have unit tests in this PR that cover every new branch, so the parser change While checking: in the current On the failing CII looked at each one; none of them are caused by this change.
On the shape of the change itself: all new locals are declared with the rest of |
Let's start with a small write up (script in the broadest sense) how the volume was created and make it reproduceable. Independent of this PR. The PR can use stand-alone key bag records for unit testing
Yeah this sometimes happens due to connectivity issues on the action runners.
This is due a recent-ish change in the Github action cygwin configuration, I'll push an update. |
|
Sounds good — I'll keep the specimen work out of this PR and write it up separately. Happy to put it wherever suits; The PR already matches what you describe — the tests are stand-alone key bag records compiled into One thing worth saying up front, because it decides what a FileVault specimen would even be good for. The 22-byte KEK metadata ends in 16 bytes that are constant per install: identical for both crypto users within one install, and different between two independently installed machines that use the same account name and password. They are bound to that machine's Secure Enclave. So a FileVault system-volume image cannot be unlocked by anyone who did not create it — it would be parsing-coverage only, never an unlock test vector. That is the main reason I think stand-alone records are the right call here regardless of how the specimen question lands. Separately, and more useful in the short term: the diskutil apfs encryptVolume ${VOLUME_DEVICE} -user disk -passphrase testafter Still open from last time whenever you get to it: happy to split the bounds checking out of the format change into its own commit or PR if you'd prefer them separate. |
Thanks that is fine. Using the dfirlabs/apfs-specimens project is preferred.
That is fine the scripts are intended for creating test data (not production code), both encryption in progress and completed are useful test scenarios.
That is fine, having it scripted, at minimum allows someone to recreate comparable test data.
Fine to keep them in this one. |
|
Regarding https://github.com/libyal/libfsapfs/actions/runs/30320701280/job/90181237038?pr=89 rebasing with HEAD should resolve this |
|
A correction to my previous comment — I overstated the "cannot be unlocked" claim, and since this is a parsing library it's worth scoping properly. Architecture. What I described is specific to the Apple-Silicon (VZ / VMAPPLE) guest I measured. On Intel, the equivalent device key is derived in software from a machine identifier ( Mechanism. I leaned on the 16 constant bytes as though they were the binding; they aren't. They're a per-install label (a wrapping-context identifier — and on this container it is the public cirruslabs base-image value, shared by anyone who pulls that image, which is also why nothing private is in the test vectors). The actual machine-binding is upstream of the on-disk record: the password-derived key is combined with an off-disk Secure-Enclave secret before the RFC-3394 unwrap, so the KEK is not a function of on-disk bytes alone. What's actually established. On the Apple-Silicon guest, a reader VM with a different Secure-Enclave context rejects both the correct password and the personal recovery key, and replacing just the VM's auxiliary storage ( None of this changes the specimen conclusion: stand-alone key bag records for parsing coverage is the right call, and a disk-image FileVault specimen exercises the parser but is not an unlock test vector on this platform. Apologies for the imprecision in the earlier version. |
…ainer VEK entry, big-endian DER long-form lengths)
Reduced the diff of the previous commit and added the bounds checking that change makes necessary. * Gated the relaxed keyblob[4] and keyblob[5] guards on a 22-byte keyblob[2] metadata attribute, so that a corrupt legacy password KEK, such as one with a 15-byte keyblob[5], is still rejected instead of parsing with an all-zero salt and being reported as a wrong password. * Added availability checks ahead of every DER tag and length byte read. Accepting the 0x81 long form consumed a length byte before the existing bounds check, so a record ending in a bare <tag> 0x81 read past the data. The same was already reachable through the 0x82 form, and through the attribute tag and length byte pair at all four tag and length decode sites. * Bound the object value data size by the number of bytes remaining after the length bytes instead of by a hardcoded 2-byte header, which under-counts by 1 or 2 bytes for the long forms. * Bound the attributes by the object value data instead of by the data, so that trailing data is no longer parsed as an attribute. * Corrected the number of iterations debug output format specifier to PRIu64. * Removed a debug output statement unrelated to the format change.
Covers every branch the macOS 26 keybag change adds, which previously had no test at all. Real records, from a disposable macOS 26.5.2 virtual machine whose account password is "admin": - data1: per-user KEK record (162 bytes) with a 22-byte keyblob[2] metadata attribute. - data2: personal recovery key KEK record (162 bytes) of the same container, a second independent record with a 22-byte keyblob[2] metadata attribute and a different HMAC, identifier, wrapped KEK, iteration count and salt. - data3: container key bag VEK entry (388 bytes), which uses 2-byte long-form DER lengths on the object and on the keyblob, a 1-byte long-form DER length on keyblob[8], a 16-byte keyblob[4] and a 3-byte keyblob[5]. Records derived from those: - data4: KEK record with the pre-macOS 26 8-byte keyblob[2] metadata attribute, to safeguard the original code path. - data5 and data6: records that use the 1-byte and 2-byte long-form DER lengths on the object, on the keyblob and on the keyblob attributes. The unsupported long-form attributes precede keyblob[4] and keyblob[5], so a change that stopped parsing at them is caught by the number of iterations and salt asserts. - data7: record followed by trailing data that itself looks like a wrapped KEK packed object attribute, which must not be parsed. - error_data1 to error_data4: an unsupported 12-byte keyblob[2] metadata attribute, an 8-byte keyblob[2] metadata attribute combined with a 15-byte keyblob[5] or with a 16-byte keyblob[4], and an empty keyblob[4]. - error_data5 to error_data14: minimal records truncated at a DER tag or length byte, and records with attribute value data running past the object that contains it. Every regular case starts from a freshly initialized key encrypted key, since libfsapfs_key_encrypted_key_read_data only sets the attributes a record actually carries, and asserts the parsed HMAC, identifier, wrapped KEK, salt, number of iterations and encryption method.
731b4da to
3d8afbe
Compare
Summary
libfsapfs 20240429 cannot open a FileVault-encrypted APFS container written by a recent macOS (observed on macOS 26.5.2, build 25F84). It aborts while parsing the volume keybag's KEK record with:
Two on-disk shapes have outgrown fixed-size assumptions in
libfsapfs_key_encrypted_key.c, and there is one latent DER-length bug that this also fixes. None of this changes the cryptography — it is a parsing/interoperability fix (see "Scope" at the end).What changed on disk
Each keybag record is a DER SEQUENCE containing context-specific implicitly tagged fields. Two record kinds are affected.
1. The volume-keybag KEK records —
keyblob[2]metadata is 22 bytes (was 8)A per-user KEK record from a disposable test VM (login password
admin):The legacy 8-byte struct interprets offsets 0–3 as
encryption_methodand 4–7 asunknown1[2]/unknown2/unknown3. In the observed 22-byte value, offsets 0–3 decode little-endian as0x49— outside libfsapfs's accepted set{0, 2, 16}, so they are evidently a flags field, not a method — and offsets 6–21 are a 16-byte, UUID-shaped value (the six-byte prefix is retained; the UUID replaces the oldunknown2/unknown3bytes). This 22-byte layout is not novel — apfs-fuse has modeled it since 2023 asstruct key_info_t { uint32_t flags; uint8_t unk_04; uint8_t unk_05; uint8_t uuid[16]; }— libfsapfs simply has not been updated for it. Mapping this record to method 0 (below) is an implementation inference based on the 40-byte wrap.2. The container-keybag VEK entry (
KB_TAG_VOLUME_KEY) — larger, with long-form lengthsThis entry exceeds 255 bytes, so the outer SEQUENCE and the
[3]keyblob use0x82(2-byte) long-form lengths, and[8]uses an0x81(1-byte) long-form length. Its[4]is 16 bytes and[5]is 3 bytes (both differ from the documented password-protected layout — this entry is password-independent, so those slots are not a PBKDF2 iteration count / salt), and it carries three extra tags[6]/[7]/[8].The fixes (all in
libfsapfs_key_encrypted_key.c)0x82long-form lengths big-endian. DER lengths are big-endian, but the reader usedbyte_stream_copy_to_uint16_little_endian(four call sites). On the VEK entry above,a3 82 01 4dis otherwise read as0x4d01 = 19713instead of333. This is a latent bug independent of macOS 26: it misdecodes any0x82-encoded (2-byte) long-form length whose two length octets differ (e.g.01 4d).0x81(1-byte) long-form lengths in the nested wrapped-KEK sub-parser. The top-level parser already handles long-form (it reads the KEK record's own30 81 9f), but the nested wrapped-KEK-object sub-parser accepts only0x82, so[8]'s88 81 b8is rejected.0x81-> 3,0x82-> 4; short-form lengths keep the 2-byte header) instead of a hard-coded 2, so the sub-object starts on its real tag byte.keyblob[2]in addition to 8. In the 22-byte layout the first 4 bytes are a flags field, not a validencryption_method(the observed value is outside{0, 2, 16}), so override the parsed method to 0 — whose sizing (32-byte key, 40-byte wrap) matches the observed 40-bytewrapped_key— and parsing proceeds.[4]/[5]handling in the shared record parser (this affects both record kinds): read[4]as a PBKDF2 iteration count only for lengths 1–8 (zero is still rejected), and copy[5]as a salt only when exactly 16 bytes; otherwise leave them uninterpreted. For the KEK records this is a no-op (their[4]is 1–8 bytes and[5]is 16); it lets the container VEK entry's 16-byte[4]and 3-byte[5]be skipped.The extra tags
[6]/[7]/[8]need no per-tag handling — they fall through the existingdefault: breakonce the length decoding above lets the parser advance past them.Testing
The patch applies cleanly to current
mainand, built with--enable-debug-output, compiles without any warning on the changed file (and is-std=c89 -pedanticclean there). Against a real macOS 26.5.2 (25F84) FileVault container from a disposable VM,fsapfsinfothen parses the container keybag and both KEK records where it previously aborted. (The sample bytes above are from that throwaway VM, whose login password is the literaladmin.) I have not run thetests/suite; the change is confined to the DER/keybag record parser.Scope — this is a parsing fix, not an unlock
Importantly, on the guest examined here the password does not unwrap the KEK offline:
PBKDF2-HMAC-SHA256(password, salt, iterations)followed by RFC-3394 unwrap of the 40-bytewrapped_keyfails theA6A6A6A6A6A6A6A6integrity check with the correct password (checked across ~150 salt/iteration/key-length variants and two independent unwrap implementations). The KEK appears bound to a wrapping context that is not present on disk (on a Virtualization.framework guest, plausibly the out-of-process virtual Secure Enclave). So this PR makes libfsapfs parse these keybags rather than aborting; it does not by itself decrypt such a volume.