The DailyCue iOS application demonstrates several strong security practices: SQLCipher database encryption with a device-bound Keychain passphrase generated via CSPRNG, exclusive use of parameterized GRDB queries (no SQL injection exposure), proper foreign-key constraints enforced at the SQLite layer, URLFileProtection.completeUnlessOpen on the database directory, and Keychain storage with kSecAttrAccessibleWhenUnlockedThisDeviceOnly. No network calls exist, eliminating an entire attack surface category. No hardcoded credentials or API keys were found. Two findings rise to Medium severity, two to Low, and three are Informational.
| # | Finding | Severity | Status | Reference | File(s) |
|---|---|---|---|---|---|
| 1 | HMAC verification intentionally non-fatal — cross-device migration design trade-off | Info | Design Decision | — | BackupManager.swift:117 |
| 2 | User PII (routine/event names) logged as privacy: .public in OSLog | Medium | Fixed | CWE-532, OWASP M2, MASTG MSTG-STORAGE-3 | RoutineRecord.swift:79,127 · HomeViewModel.swift:272,290 · RoutineLibraryViewModel.swift:116 |
| 3 | Backup HMAC key stored in Keychain with wrong type attributes (EC vs. symmetric) | Medium | Fixed | CWE-327, OWASP M10 | BackupKeyStore.swift |
| 4 | Backup export file written to temp dir without explicit file protection class | Low | Deferred | CWE-922, OWASP M2, MASTG MSTG-STORAGE-1 | SettingsViewModel.swift:196 |
| 5 | Database directory not excluded from iCloud backup | Low | Deferred | CWE-312, MASTG MSTG-STORAGE-8 | DailyCueDatabase.swift:101 |
| 6 | SQLITE_DEBUG compiled into production build (vendored GRDBCipher) | Info | — | CWE-489 | GRDBCipher/Package.swift:59 |
| 7 | Hardcoded app version string in backup envelope metadata | Info | — | — | BackupManager.swift:79 |
The HMAC-SHA256 authenticity check logs a warning on mismatch but allows the import to continue. This is a deliberate design decision required to support cross-device migration — the primary use case for backup/restore.
The HMAC key is generated and stored in the iOS Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly. This attribute explicitly marks the key as device-bound — it is excluded from iCloud backup and cannot be transferred to another device. Therefore, any backup imported on a new device will always fail the HMAC check. Making the check fatal would silently break every cross-device migration.
if let hmac = envelope.hmac {
let valid = try await backupKeyStore.verify(payloadData, tag: hmac)
if !valid {
logger.warning("Backup HMAC mismatch — file was not created by this device.")
// Non-fatal by design: HMAC key is device-bound (Keychain ThisDeviceOnly).
// A mismatching HMAC on a new device is the expected case, not an attack.
}
}
The SHA-256 checksum (enforced — import aborts on mismatch) still protects against accidental file corruption. The HMAC provides same-device provenance only; its mismatch on a new device is expected and correct.
Residual risk: a crafted backup (forged SHA-256 + malicious payload) can be imported. This requires the attacker to construct a valid JSON file and socially engineer the user into importing it — a meaningful but accepted threat for a local-only, offline application with no network surface.
OSLog's release-build default is to redact dynamic string interpolations, replacing values with <private> in system logs. Explicitly marking a value with privacy: .public overrides this protection, making the value visible in:
User-generated routine and event names represent health-sensitive PII for DailyCue's target population. Titles such as "Take Methotrexate", "AA Meeting", "Chemotherapy Session", or "Psychiatrist Appointment" could appear in system diagnostic bundles routinely collected for crash reporting, constituting an inadvertent disclosure of medical information.
Affected log calls include:
// RoutineRecord.swift:79
logger.error("Failed to decode contacts for routine '\(name, privacy: .public)'")
// HomeViewModel.swift:272
logger.error("Failed to delete future appointments '\(title, privacy: .public)'")
// HomeViewModel.swift:290
logger.error("Failed to delete routines by title '\(title, privacy: .public)'")
Change all user-generated string interpolations from privacy: .public to privacy: .private. Reserve privacy: .public for technical identifiers (integer IDs, error codes, non-PII counts) only:
// Before — exposes PII in system logs:
logger.error("Failed to decode contacts for routine '\(name, privacy: .public)'")
// After — PII redacted in production:
logger.error("Failed to decode contacts for routine '\(name, privacy: .private)'")
All 5 callsites changed to privacy: .private: RoutineRecord.swift lines 79 and 127, HomeViewModel.swift lines 272 and 290, RoutineLibraryViewModel.swift line 116. Both the user-generated string and the error object are now redacted in production system logs.
The HMAC key is 32 bytes of raw SymmetricKey material intended for HMAC<SHA256>. It is stored in the Keychain as a kSecClassKey item with the following incorrect type attributes:
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, // EC P-256 — WRONG kSecAttrKeySizeInBits as String: 256
kSecAttrKeyTypeECSECPrimeRandom is the Keychain type identifier for ANSI X9.62 Elliptic Curve Secp256r1 private keys — a completely different binary format from a raw 32-byte symmetric key. This creates two concrete risks:
kSecClassKey items, SecItemCopyMatching may reject the item. Since getOrCreateKey generates a fresh key when retrieval fails, all existing HMAC tags would fail verification permanently — every backup would appear tampered.errSecDuplicateItem handling: Unlike DatabaseEncryption.storePassphrase, getOrCreateKey does not handle the errSecDuplicateItem Keychain status. If retrieval fails for any reason but the item still exists (which can happen due to the type mismatch), SecItemAdd returns errSecDuplicateItem, causing the function to throw instead of returning the cached key.Store the HMAC key using kSecClassGenericPassword (consistent with DatabaseEncryption.swift), removing the mismatched type attributes and adding errSecDuplicateItem handling:
// Use kSecClassGenericPassword for raw symmetric material:
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.dailycueplanner.backup",
kSecAttrAccount as String: "hmac-key",
kSecValueData as String: keyData,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
let status = SecItemAdd(addQuery as CFDictionary, nil)
guard status == errSecSuccess || status == errSecDuplicateItem else {
throw KeychainError.unableToStore(status)
}
BackupKeyStore.swift rewritten to use kSecClassGenericPassword with kSecAttrService/kSecAttrAccount — the same pattern as DatabaseEncryption.swift. The incorrect kSecAttrKeyTypeECSECPrimeRandom and kSecAttrKeySizeInBits attributes are removed. errSecDuplicateItem is now guarded. A best-effort migration reads the legacy kSecClassKey item on first run, deletes it, and re-stores the key material under the correct class.
The backup export writes an unencrypted JSON file to FileManager.default.temporaryDirectory without applying an explicit file protection class:
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent("DailyCue_Backup_\(UUID().uuidString.prefix(8)).json")
try await backupManager.exportTo(url: tempURL)
The temporary directory may use NSFileProtectionCompleteUntilFirstUserAuthentication or lower — weaker than the URLFileProtection.completeUnlessOpen explicitly applied to the database directory. The backup file contains all contact names, phone numbers, event titles, routine names, and the owner name in readable JSON. If the app crashes after export but before the share-sheet cleanup fires, the file persists in the temporary directory indefinitely with no automatic expiration.
After writing the backup file, apply the strongest feasible protection class before making it accessible to the share sheet:
try (tempURL as NSURL).setResourceValue(
URLFileProtection.complete,
forKey: .fileProtectionKey
)
Additionally, implement a crash-safe cleanup mechanism (e.g., enumerate temp files on next launch and delete any matching the backup filename pattern) rather than relying solely on share-sheet dismissal.
The database is stored in applicationSupportDirectory/com.dailycueplanner/, which is included in iCloud backups by default. The code never sets NSURLIsExcludedFromBackupKey. The SQLCipher-encrypted database blob and its WAL/SHM sidecar files are uploaded to iCloud. The Keychain passphrase is stored with kSecAttrAccessibleWhenUnlockedThisDeviceOnly, which correctly excludes it from iCloud backup — so an iCloud copy of the encrypted database cannot be decrypted without the originating device. However, Apple's MASTG guidance explicitly recommends excluding sensitive databases from iCloud backup regardless of encryption state, as the encrypted blob may be subject to cryptanalysis as hardware and algorithmic capabilities evolve.
After creating the database directory, apply the backup exclusion key:
let dbDir = appSupport.appendingPathComponent("com.dailycueplanner", isDirectory: true)
try FileManager.default.createDirectory(at: dbDir, withIntermediateDirectories: true)
// Exclude from iCloud backup:
try (dbDir as NSURL).setResourceValue(true, forKey: .isExcludedFromBackupKey)
| # | Observation | File | Assessment |
|---|---|---|---|
| 6 | SQLITE_DEBUG compiled into production build in vendored GRDBCipher |
GRDBCipher/Package.swift:59 |
SQLITE_DEBUG enables SQLite internal assertions, mutex debug counters, and additional diagnostic behavior intended only for development. It adds minor overhead and exposes the sqlite3_debug_mutex_counter() debug API in production. Remediate by removing the define from the CSQLite cSettings array or gating it on a debug build flag. Adding NDEBUG to neutralize assert() calls is a viable minimum mitigation. |
| 7 | Hardcoded app version string "1.2.13" in backup envelope metadata |
BackupManager.swift:79 |
All backup files produced since v1.2.13 are mis-labeled with a stale version, making forensic analysis and future backup-format migration logic unreliable. Fix: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown". |
SecRandomCopyBytes (CSPRNG), stored in Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly (device-bound, excluded from iCloud backup). Strong, correct implementation.? arguments or Column-filter API — no raw SQL string concatenation of user input found anywhere in the codebase. SQL injection is structurally impossible.GetFutureAppointmentsByTitleUseCase.swift:11–15 — \, %, and _ are escaped with ESCAPE '\' before pattern construction, preventing wildcard injection.URLFileProtection.completeUnlessOpen explicitly applied to the database directory and file, providing hardware-backed at-rest encryption beyond SQLCipher.foreignKeysEnabled = true) on all GRDB connections, with onDelete: .cascade on child tables — referential integrity is maintained at the SQLite layer.MFMessageComposeViewController — the app never sends SMS directly; users retain full control of every message send.actor isolation used throughout for all repositories, the database pool, and NotificationManager — thread-safety is enforced by the Swift concurrency runtime, not ad-hoc locking.NSContactsUsageDescription and only requested when the user initiates a contact-selection flow — no over-permissioning.BGTaskSchedulerPermittedIdentifiers in Info.plist matches the identifier registered in NotificationRefreshTask.swift — no privilege escalation via BGTask identifier spoofing.