Lab 9.1 — Secure Notes App
Goal: Take an intentionally insecure notes app and migrate it to use Keychain-stored encryption keys, biometric unlock, encrypted persistence, and a complete Privacy Manifest.
Time: ~3 hours
Prerequisites:
- Phase 9 chapters 9.2 (Secure Data Storage), 9.4 (Authentication & Biometrics), 9.7 (Secure Coding), 9.9 (Privacy)
- A real iOS device with Face ID or Touch ID (simulator works for most but not biometric flow)
- Xcode 16+
Setup
- Clone the starter:
git clone https://github.com/bl9/swift-engineer-labs.git
cd swift-engineer-labs/09-security/secure-notes-app/starter
open SecureNotesApp.xcodeproj
- Inspect the starter — deliberately insecure:
// AppState.swift (starter — DO NOT SHIP)
@Observable final class AppState {
var notes: [Note] = []
private let storeKey = "notes.store"
init() {
if let data = UserDefaults.standard.data(forKey: storeKey),
let decoded = try? JSONDecoder().decode([Note].self, from: data) {
self.notes = decoded
}
}
func save() {
let data = try! JSONEncoder().encode(notes)
UserDefaults.standard.set(data, forKey: storeKey)
}
}
Note the issues: plaintext in UserDefaults, force-try, no auth gate, no Privacy Manifest.
Tasks
Task 1 — Move persistence out of UserDefaults
Replace UserDefaults persistence with file-based storage under Application Support, using .complete Data Protection:
final class NotesStore {
private let url: URL = {
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
try? FileManager.default.createDirectory(at: support, withIntermediateDirectories: true)
return support.appendingPathComponent("notes.encrypted")
}()
func load(key: SymmetricKey) throws -> [Note] {
guard FileManager.default.fileExists(atPath: url.path) else { return [] }
let blob = try Data(contentsOf: url)
let box = try AES.GCM.SealedBox(combined: blob)
let plain = try AES.GCM.open(box, using: key)
return try JSONDecoder().decode([Note].self, from: plain)
}
func save(_ notes: [Note], key: SymmetricKey) throws {
let plain = try JSONEncoder().encode(notes)
let box = try AES.GCM.seal(plain, using: key)
try box.combined!.write(to: url, options: [.completeFileProtection])
}
}
Task 2 — Generate and store the encryption key in Keychain
On first launch, generate a 256-bit symmetric key and store it in Keychain with biometric gating:
enum KeyVault {
static let account = "com.acme.securenotes.dataKey"
static func loadOrCreate() throws -> SymmetricKey {
if let data = try read() { return SymmetricKey(data: data) }
let key = SymmetricKey(size: .bits256)
try store(key.withUnsafeBytes { Data($0) })
return key
}
private static func store(_ data: Data) throws {
let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
.biometryCurrentSet,
nil
)!
let q: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecValueData as String: data,
kSecAttrAccessControl as String: access,
]
SecItemDelete(q as CFDictionary)
let status = SecItemAdd(q as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.unhandled(status) }
}
private static func read() throws -> Data? {
let q: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecUseOperationPrompt as String: "Unlock your notes",
]
var result: AnyObject?
let status = SecItemCopyMatching(q as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess else { throw KeychainError.unhandled(status) }
return result as? Data
}
}
Task 3 — Wire the biometric unlock flow
On app launch, show a locked screen. User taps “Unlock with Face ID” → KeyVault.loadOrCreate triggers the biometric prompt → notes decrypt and display.
Implement an AppLockState @Observable that drives the UI between .locked, .unlocking, .unlocked(NotesStore), and .failed(error).
Handle the passcode-fallback path explicitly: if LAError.biometryNotAvailable or .biometryNotEnrolled, fall back to .deviceOwnerAuthentication (passcode).
Task 4 — Add the Privacy Manifest
Create PrivacyInfo.xcprivacy at the project root with:
NSPrivacyTracking = falseNSPrivacyCollectedDataTypes: User Content (notes), Linked = false, Tracking = false, Purpose = App FunctionalityNSPrivacyAccessedAPITypes:NSPrivacyAccessedAPICategoryUserDefaultswith reasonCA92.1,NSPrivacyAccessedAPICategoryFileTimestampwith reasonC617.1
Task 5 — ATS audit
Open Info.plist and verify no NSAppTransportSecurity exceptions. Add a Run Script build phase that fails the build if any are present in Release config.
Task 6 — Add the usage description string
NSFaceIDUsageDescription = “Face ID protects your notes so only you can read them.”
Build & verify
- Run on a real device. First launch should generate the key and prompt for biometric setup (depending on flow).
- Add a note, close the app fully, reopen → should require Face ID to unlock.
- With the device locked (long-press power, then unlock screen), verify that the file at
Application Support/notes.encryptedcannot be read by a tethered session — the.completeFileProtectionflag should block it. - Use the iOS Files app or a backup inspector to verify nothing about the notes leaks via UserDefaults or any unencrypted file.
- Run
stringsagainst the build product.app/SecureNotesApp— should NOT find any of your test note content.
Stretch goals
- Re-lock after timeout — after 30 seconds in background, re-require biometric unlock.
- Decoy mode — secondary fingerprint enrollment unlocks a different notes database (the “decoy”). Hint: use distinct Keychain accounts gated on different
SecAccessControlflags. - Export-encrypted — implement an “Export Notes” feature that produces an age-encrypted file the user can share, with the key derived from a passphrase.
- SQLCipher — replace the AES.GCM blob with a SQLCipher-encrypted SQLite database via GRDB.
Notes
- The
.biometryCurrentSetflag means re-enrolling Face ID invalidates the key and loses the notes. Acceptable for a security-first app; document clearly to users in onboarding. - The Keychain item survives app uninstall by default. To clean up on uninstall, you’d need to clear on first launch detection — but it’s an explicit choice and most secure-notes apps keep data across reinstalls intentionally.
- Don’t store decrypted notes in UserDefaults caching or anywhere outside the in-memory
AppState.