Text2Store

Developer notes

Unwrapping an encrypted backup

If you tick "Encrypt local backup" in Finder, your messages are genuinely encrypted and nothing reads them without your password. Here is the key ladder that turns that password into plaintext, why we wrote it from the published format instead of borrowing working code, and the one-line build setting whose absence cost 52 seconds on every unlock for weeks.

Encrypted backups are the ones worth supporting well. They are the only kind that carries your Health and Keychain data, they are what any careful person picks, and they are the reason a stolen laptop full of archives is a non-event. They are also the point where a message reader stops being a database browser and becomes a piece of cryptographic plumbing you had better get exactly right.

Written from the spec, not from the code that worked

There is working open-source code that unlocks these backups. We did not use it. The tool we drive for device communication, pymobiledevice3, is GPL-licensed and stays an arm's-length subprocess for exactly that reason (that story is its own chapter). Porting its decryption into our Rust would have pulled a GPL obligation straight into the app binary, so the keybag implementation was written against the publicly documented format instead: the TLV keybag layout, the double PBKDF2 derivation used since iOS 10.2, RFC 3394 key unwrapping, and AES-256-CBC file payloads. Slower to build, and the only version we could actually ship.

This is worth saying plainly because the lesson generalizes: a license is not paperwork you handle at release time. It decides, months earlier, whether a given piece of code can exist inside your process at all. We hit the same wall from the other direction with ffmpeg.

The ladder

Nothing in an encrypted backup is encrypted with your password. Your password unlocks a key, that key unwraps other keys, and those keys decrypt files. Four rungs:

One. Manifest.plist announces the backup is encrypted and carries two things: the BackupKeyBag and a wrapped ManifestKey. Two. The keybag is a TLV stream, four-byte tag and four-byte big-endian length, holding SALT and ITER, the modern DPSL and DPIC parameters, and then one block per protection class, each with its class number, wrap mode, and that class's wrapped key. Three. The password goes through PBKDF2-HMAC-SHA256 against DPSL for DPIC rounds, then PBKDF2-HMAC-SHA1 against SALT for ITER rounds. The result is the passcode key, and it exists only to unwrap the class keys via RFC 3394. Four. Manifest.db decrypts under the unwrapped ManifestKey with AES-256-CBC and a zero IV, and inside it every file's record carries its own protection class, its own wrapped key, and its true size.

That last field matters more than it looks. CBC works in sixteen-byte blocks, so a decrypt always hands back a multiple of sixteen, and the tail is padding that is not marked as padding. The manifest's declared size is the only thing that tells you where the real file ends. Trust the ciphertext length instead and every file you extract is subtly, invisibly wrong at the end.

The 58-second unlock

Apple's modern keybag runs ten million PBKDF2 iterations, which is the entire point: it makes guessing expensive. It is supposed to cost a noticeable fraction of a second. In our debug builds it was taking 58 seconds on an i5-8500. Every unlock. Every dev session.

The obvious fix is Cargo's per-package optimization override, which lets a debug build compile one slow dependency at full speed while your own code stays debuggable. We added it for the crypto crates. Nothing changed, and the reason is a genuinely sharp edge in how Rust compiles.

The RustCrypto entry points involved, pbkdf2_hmac and the CBC mode wrappers, are generic functions. A generic does not compile inside the crate that defines it. It monomorphizes into the crate that instantiates it, and it compiles at that crate's optimization level. We were calling them from the app crate, so they were codegen'd into the app crate, at the debug profile's opt-level 0. The [profile.dev.package.pbkdf2] override was working perfectly and reaching nothing, because almost nothing was actually being compiled inside pbkdf2. The override only ever covers code that codegens within the named crate, like sha2's concrete compression core, which is exactly why the setting appears to work for some dependencies and silently does nothing for others.

The fix is structural rather than clever. The hot generic instantiations moved into a tiny crate of their own, t2s-crypto-hot, which exists for no reason except to be the crate those generics monomorphize into, and which the app manifest pins at opt-level 3 even in debug builds. Same code, same call sites, 58 seconds became 5.4. The app crate stayed fast to compile and pleasant to step through.

If you take one thing from this chapter, take that: a per-package profile override cannot speed up a generic you instantiate somewhere else. Move the instantiation, or keep paying for it.

Keys that leave when you do

Once derived, the class keys live in memory for the session and nowhere else. The struct that holds them holds only derived key material, never the password itself. It implements Drop to zeroize its keys, so heap that once held key material is wiped when the last clone goes away rather than being handed back to the allocator intact, where it could reach swap or a crash dump. The password is cached separately and equally briefly, purely so that taking a backup immediately after unlocking does not prompt you twice.

None of that is exotic. It is the boring version of handling key material, and boring is the correct ambition. The interesting engineering in this area was never the cryptography, which is well specified and widely documented. It was the licensing decision that shaped where the code could live, and a build-profile subtlety that made correct code fifty times slower than it needed to be.