DEV Community

Simple Memo
Simple Memo

Posted on

The cipher was the easy part: AES-GCM in a personal app

Encrypting the notes in my iOS app came down to three lines of CryptoKit. Those lines took about ten minutes to write. Everything they quietly stand on took the next four months to get right. So this is me disassembling the whole path, top to bottom: from a line you typed to the place the key sleeps at night. Every failure that cost me a real weekend lived in a layer the tutorials finish in one sentence.

The reframe I want to sell you is this: the cipher is the commodity part. AES-GCM is a solved, boring, well-audited primitive, and Apple hands it to you behind a friendly API. The hard part is everything around it, which is key management, and key management is not cryptography. It is bookkeeping, lifecycle, and a set of honest decisions about what you are actually defending against. I got all of those wrong at least once.

What the teardown found:

  • The AES-GCM call is a few lines and you will not spend meaningful time there. Budget your attention for the four layers underneath it.
  • The nonce is a trap only if you try to be clever. Let CryptoKit generate it, store it inline, and never invent your own counter.
  • Where the key lives decides whether your data survives a device restore. I learned this the expensive way, from a user who could not read their own notes on a new phone.
  • Put a version byte in front of every blob on day one, or you will regret it the first time you have to change anything.

The cipher everyone shows you

Here is the whole cipher, the part a tutorial spends its entire length on:

import CryptoKit

let key = SymmetricKey(size: .bits256)
let sealed = try AES.GCM.seal(note.data(using: .utf8)!, using: key)
let blob = sealed.combined!            // nonce + ciphertext + tag, ready to store
Enter fullscreen mode Exit fullscreen mode

To read it back:

let box = try AES.GCM.SealedBox(combined: blob)
let plaintext = try AES.GCM.open(box, using: key)
Enter fullscreen mode Exit fullscreen mode

That is it. seal generates a fresh random nonce for you, encrypts, and computes a 128-bit authentication tag that refuses to decrypt if a single byte was flipped. The combined property packs the 12-byte nonce, the ciphertext, and the 16-byte tag into one Data you can write to disk. If those five lines were the job, this post would be over. They are maybe two percent of the work.

Layer one: the nonce nobody warned you about

Galois/Counter Mode has one catastrophic failure mode, and it is not a weak cipher. It is a repeated nonce. Encrypt two different messages under the same key and the same nonce and you do not leak a little. You leak the XOR of the two plaintexts, and worse, you hand an attacker the pieces to forge messages that will pass the authentication check. This is the single scariest sentence in the whole subject, and it is exactly why a tutorial that skips it is doing you harm.

Here is the relief: if you do what I showed above and let CryptoKit pick the nonce, you are almost certainly fine. It draws 12 random bytes from a cryptographically secure source every time, and the collision odds are astronomically small for any personal-scale data set. The trap opens the moment you decide to be helpful. Early on I tried, at various points, to store the nonce in its own column to "save space," to rederive it from a message counter, and to reuse one nonce across a batch so decryption would be faster. Every one of those instincts is a way to walk yourself into the failure mode on purpose. The correct move is the lazy one: generate, store inline with combined, never reuse, never reconstruct.

Layer two: where does the key actually live?

Now the real question, the one the cipher API cannot answer for you: where do you keep the key? A 256-bit SymmetricKey is just bytes. If an attacker can read those bytes, the encryption is decoration. So every option below is really a claim about who can reach the key, and about what happens to it when the phone changes hands or gets restored from a backup.

I have shipped or seriously prototyped all five of these. Here is the honest comparison, including the part that bit me.

Where the key lives Survives a restore to a new device? What it actually protects against The catch
Hardcoded constant in the app Yes Almost nothing; it ships in every copy of the binary Anyone who unzips your app has the key. This is theater.
Derived from a user passphrase Yes, if the user remembers it A stolen device with no passphrase entered You now own a password-reset problem and a KDF (use a deliberately slow one)
Keychain, ThisDeviceOnly No Other apps, and off-device attackers Restore to a new phone and the key is gone. If the ciphertext synced, the data is now unreadable.
Keychain, AfterFirstUnlock (syncable) Yes, via encrypted Keychain backup Other apps and casual access Wider exposure window; the key rides along in iCloud Keychain
Secure Enclave, wrapping the key No (the Enclave key is device-bound) Extraction even off a jailbroken device The Enclave cannot hold an AES key at all; it holds a P-256 key that wraps yours

That last row is the one people get wrong in conversation, so it is worth stating plainly. You cannot put a symmetric AES-256 key inside the Secure Enclave. The Enclave only stores P-256 elliptic-curve keys, and it only performs signing and key agreement. The real pattern is indirect: you generate a P-256 key that never leaves the Enclave, use key agreement plus HKDF to derive or unwrap your symmetric key on demand, and keep only the wrapped blob on disk. It is a good pattern. It is also several more moving parts than "call seal," which is the whole theme of this teardown.

Layer three: the restore that hands back unreadable data

This is the layer that cost me a weekend and one apologetic email. Early on I did the reasonable-sounding thing and stored the key in the Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly, because "this device only" sounds like the safe, private choice. It is safe, for the key. The problem is what happens next. If any of the encrypted notes are backed up or synced off the device, and the user then restores onto a new phone, the ciphertext comes back and the key does not. ThisDeviceOnly items are deliberately excluded from backups. The user is now holding a pile of their own notes that nothing on Earth can decrypt, me included.

The fix is not a better cipher. It is a decision about coupling: the key and the ciphertext have to share a fate. Either both survive a restore or neither does. If the data syncs, the key has to be recoverable too, which usually means a syncable Keychain item or a passphrase the user can re-enter. If the key is truly device-bound, then the ciphertext must be device-bound as well, and you owe the user a loud, early warning that a new phone means starting over. I now write that fate-sharing rule on the same mental line as the seal call, because the cipher never once failed me. The lifecycle did.

Do you even need this? A counter-take on your own threat model

Here is the uncomfortable question I should have asked before writing any of it: what am I actually defending against? iOS already encrypts app data at rest. When the device is locked, files marked with Data Protection (NSFileProtectionComplete) are sealed by a key tied to the passcode and the Secure Enclave. For a large class of personal apps, that is the real threat, a lost or stolen phone, and the OS has already handled it for free, more thoroughly than my hand-rolled layer does.

So when does app-level AES-GCM earn its keep? In my experience, roughly three situations, and you should be honest about whether you are in one. First, the data leaves the device through a channel you do not trust and cannot mark protected, such as your own sync server or an export file. Second, the data lands in a shared container or an App Group where file protection is weaker than you assume. Third, you have a specific promise to keep, like "even I cannot read your notes," that the OS default does not make on your behalf. If none of those is true, adding encryption can be worse than adding nothing, because it hands you the key-loss failure from layer three in exchange for protection you already had. I still ship the layer, because one of those is true for me. I am genuinely not sure I would add it to a simpler app, and I would like to be argued into or out of that.

Layer four: the version byte you will wish you wrote

The last layer is the cheapest to add and the most expensive to skip. Every encrypted blob I write starts with a single version byte in front of the CryptoKit combined data. Right now it is always 0x01. It does nothing today. But the first time I need to rotate a key, change an accessibility class, or move from this construction to whatever replaces it, that byte is the difference between a clean migration and a guessing game against my own past self. When I read a blob back, the first thing the decryptor does is switch on that byte, before it ever hands anything to CryptoKit; an unknown version fails loudly and early instead of feeding garbage into open and getting a confusing authentication error deep in the stack. Cryptographic formats are forever in the same way database schemas are forever, except you cannot read the rows to figure out what you meant. Write the version byte on day one. It is one byte.

One more honest boundary, because it is easy to oversell what any of this buys. In my app the encryption happens inside the same single action that queues the note, right before it lands in the offline Outbox I described in an earlier teardown. That protects the note at rest, on the device. The moment it goes out as an email, it is plaintext again, because email is plaintext. Encryption at rest is a statement about a stolen phone, not about the wire, and I say exactly that in the app's own copy, because a privacy claim you have to squint at is worse than none. Simple Memo is the small iOS app where I keep relearning that distinction. If pulling a subsystem apart like this is your thing, I did the same to the cross-app write path in another post; the encryption here runs in the breath just before that write.

A few questions I had to answer for myself

Do I need to store the nonce separately from the ciphertext? No. The combined property already puts the 12-byte nonce in front of the ciphertext and the tag behind it. Store that one blob, and SealedBox(combined:) pulls the nonce back out on the way in. Storing it separately is extra surface area for a bug and buys you nothing.

Can I keep the AES key in the Secure Enclave for maximum safety? No, and this is the most common misconception I hear. The Secure Enclave holds P-256 keys and performs signing and key agreement only. To "use the Enclave" for symmetric encryption you generate a P-256 key inside it and use key agreement plus HKDF to wrap or derive your AES key, keeping only the wrapped form on disk. It is real protection, and it is more plumbing.

Is CryptoKit's AES-GCM interoperable with my backend? Usually yes, but check two things: the nonce length and the byte layout. CryptoKit uses a 12-byte nonce and appends the 16-byte tag; many server libraries expect the same 12-byte IV but hand you the tag as a separate output. If your decrypt fails on the server, it is almost always the tag sitting in a place the other side did not expect, not the cipher itself disagreeing.

So that is the whole stack

Cipher, nonce, key, restore, threat model, version byte. The cipher was the only part I never had to touch twice.

If you have shipped encryption in a small app, I want to know which layer drew blood for you. Was it a nonce you reused by being clever, a key that did not survive a restore, a threat model you could not actually name out loud, or a format you painted yourself into? I hit the restore one hardest, and I still suspect there is a fifth failure I have simply not paid for yet. Tell me which one you hit, or tell me I am overcomplicating a problem the OS already solved. Both are useful to me.


I'm one person building one iOS app, and a surprising share of it is plumbing nobody sees, like an encryption layer whose entire job is to be boring and correct. I write here every few days about the unglamorous half of shipping something alone, mostly the parts I got wrong first. If you want to see what all that plumbing adds up to, it's over here.

Top comments (0)