iOS

DailyCue Security Review

Application: DailyCue iOS
Bundle ID: com.dailycueplanner
Version: 1.2.20 (build 36)
Review Date: 2026-06-30T00:00:00Z
Report Updated: 2026-07-02T21:00:00Z
Standards: OWASP Mobile Top 10 · CWE Top 25 · Apple MASTG
Scope: Full source — Swift, Info.plist, GRDBCipher vendor
Executive Summary

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.

0
Critical
0
High
2
Medium
2
Low
3
Info
#FindingSeverityStatusReferenceFile(s)
1HMAC verification intentionally non-fatal — cross-device migration design trade-offInfoDesign DecisionBackupManager.swift:117
2User PII (routine/event names) logged as privacy: .public in OSLogMediumFixedCWE-532, OWASP M2, MASTG MSTG-STORAGE-3RoutineRecord.swift:79,127 · HomeViewModel.swift:272,290 · RoutineLibraryViewModel.swift:116
3Backup HMAC key stored in Keychain with wrong type attributes (EC vs. symmetric)MediumFixedCWE-327, OWASP M10BackupKeyStore.swift
4Backup export file written to temp dir without explicit file protection classLowDeferredCWE-922, OWASP M2, MASTG MSTG-STORAGE-1SettingsViewModel.swift:196
5Database directory not excluded from iCloud backupLowDeferredCWE-312, MASTG MSTG-STORAGE-8DailyCueDatabase.swift:101
6SQLITE_DEBUG compiled into production build (vendored GRDBCipher)InfoCWE-489GRDBCipher/Package.swift:59
7Hardcoded app version string in backup envelope metadataInfoBackupManager.swift:79
Detailed Findings
1
HMAC Verification Intentionally Non-Fatal — Cross-Device Migration Design Trade-off
Info
File: BackupManager.swift lines 117–123

Design Rationale

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.

Hardening Recommendations (optional)

  • Surface the HMAC mismatch to the user visibly (a sheet/alert rather than only OSLog): "This backup was created on a different device — its authenticity cannot be verified. Only import files you created yourself." This informs users without blocking the migration path.
  • Consider adding a UI label to the import flow that sets user expectation about cross-device provenance.
2
User PII (Routine/Event Names) Logged as privacy: .public in OSLog
Medium
Reference: CWE-532 (Sensitive Information in Log File) · OWASP Mobile M2 · MASTG MSTG-STORAGE-3
Files: RoutineRecord.swift:79,127 · HomeViewModel.swift:272,290 · RoutineLibraryViewModel.swift:116

Description

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:

  • Console.app on any developer-connected Mac
  • System diagnostic bundles (sysdiagnose archives) shared with Apple or third parties for crash analysis
  • System logs on jailbroken devices accessible to privileged processes

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)'")

Remediation

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)'")

✓ Remediated — commit a7adb43

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.

3
Backup HMAC Key Stored in Keychain With Wrong Type Attributes
Medium
Reference: CWE-327 (Use of Broken/Risky Cryptographic Algorithm) · OWASP Mobile M10
File: BackupKeyStore.swift lines 49–68

Description

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:

  1. Silent key loss: If a future iOS version applies stricter format validation to 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.
  2. Missing 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.

Remediation

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)
}

✓ Remediated — commit a7adb43

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.

4
Backup Export File Written to Temporary Directory Without Explicit File Protection
Low
Reference: CWE-922 (Insecure Storage of Sensitive Information) · OWASP Mobile M2 · MASTG MSTG-STORAGE-1
File: SettingsViewModel.swift lines 196–199

Description

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.

Remediation

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.

5
Database Directory Not Excluded from iCloud Backup
Low
Reference: CWE-312 (Cleartext Storage in Cloud) · MASTG MSTG-STORAGE-8
File: DailyCueDatabase.swift lines 101–119

Description

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.

Remediation

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)
6–7
Informational Observations
Informational
#ObservationFileAssessment
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".
Positive Security Controls
  • SQLCipher with Keychain-stored passphrase — 32-byte passphrase generated via SecRandomCopyBytes (CSPRNG), stored in Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly (device-bound, excluded from iCloud backup). Strong, correct implementation.
  • All GRDB queries use parameterized ? arguments or Column-filter API — no raw SQL string concatenation of user input found anywhere in the codebase. SQL injection is structurally impossible.
  • LIKE query metacharacter escaping in 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.
  • Foreign key constraints enforced (foreignKeysEnabled = true) on all GRDB connections, with onDelete: .cascade on child tables — referential integrity is maintained at the SQLite layer.
  • No network connections anywhere in the codebase — OWASP M3 (Insecure Communication) and all related ATS, TLS, and certificate-pinning concerns are entirely absent.
  • No hardcoded credentials, API keys, or secrets found in any source file.
  • Backup file size limit enforced (10 MB) before parsing, preventing denial-of-service via oversized imports.
  • Backup envelope version range validated before processing — rejects backup files from future incompatible versions.
  • SMS dispatched via MFMessageComposeViewController — the app never sends SMS directly; users retain full control of every message send.
  • Swift 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.
  • SQLCipher 4.16.0 (current release) with SQLite 3.53.1 — no known CVEs affecting this version at review date.
  • Contacts permission gated by NSContactsUsageDescription and only requested when the user initiates a contact-selection flow — no over-permissioning.
  • Background task identifier in BGTaskSchedulerPermittedIdentifiers in Info.plist matches the identifier registered in NotificationRefreshTask.swift — no privilege escalation via BGTask identifier spoofing.