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:

Setup

  1. 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
  1. 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 = false
  • NSPrivacyCollectedDataTypes: User Content (notes), Linked = false, Tracking = false, Purpose = App Functionality
  • NSPrivacyAccessedAPITypes: NSPrivacyAccessedAPICategoryUserDefaults with reason CA92.1, NSPrivacyAccessedAPICategoryFileTimestamp with reason C617.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

  1. Run on a real device. First launch should generate the key and prompt for biometric setup (depending on flow).
  2. Add a note, close the app fully, reopen → should require Face ID to unlock.
  3. With the device locked (long-press power, then unlock screen), verify that the file at Application Support/notes.encrypted cannot be read by a tethered session — the .completeFileProtection flag should block it.
  4. Use the iOS Files app or a backup inspector to verify nothing about the notes leaks via UserDefaults or any unencrypted file.
  5. Run strings against the build product .app/SecureNotesApp — should NOT find any of your test note content.

Stretch goals

  1. Re-lock after timeout — after 30 seconds in background, re-require biometric unlock.
  2. Decoy mode — secondary fingerprint enrollment unlocks a different notes database (the “decoy”). Hint: use distinct Keychain accounts gated on different SecAccessControl flags.
  3. Export-encrypted — implement an “Export Notes” feature that produces an age-encrypted file the user can share, with the key derived from a passphrase.
  4. SQLCipher — replace the AES.GCM blob with a SQLCipher-encrypted SQLite database via GRDB.

Notes

  • The .biometryCurrentSet flag 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.

Next: Lab 9.2 — Certificate Pinning