Code Security · Obfuscation · Crypto (iOS)
Generated 2026-05-11 · Costco iOS
Executive summary
Strong baseline: CostcoServerTrustManager certificate pinning, AES-GCM via Keychain, NokNok PasskeyManagerKit (FIDO2), SecRandomCopyBytes for OIDC nonces, ThreatMetrix device intelligence. Notable gaps: NSAllowsArbitraryLoads = true in Notification Service Extension Info.plist (production AND QA), Privacy Manifest absent (Apple gates submissions on this since 2024), no jailbreak detection, no anti-debugging, no privacy overlay on backgrounding, and 30+ print() sites that may leak PII.
1. Code obfuscation — Swift symbol management
iOS doesn't have a direct ProGuard/R8 equivalent. The closest analogs are: stripping debug symbols from the shipped binary, uploading dSYMs to Crashlytics for symbolication while not shipping them in the IPA, and the SWIFT_OPTIMIZATION_LEVEL setting which produces less-readable disassembly.
| Setting | Status | Note |
|---|---|---|
| SWIFT_OPTIMIZATION_LEVEL = -O (release) | VERIFY | Default for release; confirm not -Onone |
| STRIP_INSTALLED_PRODUCT = YES | VERIFY | Strips local symbol table |
| STRIP_STYLE = all (release) | VERIFY | Full symbol stripping |
| DEPLOYMENT_POSTPROCESSING = YES | VERIFY | Triggers strip step |
| DEBUG_INFORMATION_FORMAT = dwarf-with-dsym (release) | VERIFY | Generates dSYM out-of-band |
| dSYM upload to Firebase Crashlytics | VERIFY | Build phase script |
| Bitcode | N/A | Deprecated by Apple |
What "obfuscation" actually means on iOS
Stripping symbols makes reverse engineering harder by removing function/method names from the binary. It does not rename or restructure — Hopper / IDA still produces readable assembly. For a retail app, symbol stripping is the appropriate level. Heavier obfuscation (string encryption, control-flow flattening via tools like iXGuard or Promon SHIELD) is typically reserved for fintech; verify with security team whether it's required here given ThreatMetrix is already in place.
Verify dSYM upload + retention
upload-symbols for Crashlytics runs in release. Archive dSYMs as a build artifact in Azure Pipelines for retention beyond Crashlytics' window. Do not ship dSYMs inside the IPA.2. Cryptographic primitives — what's used, where
| Primitive | File | Purpose | Status |
|---|---|---|---|
| SecRandomCopyBytes | Costco-Digital/Features/PasskeyManagerKit/Sources/PasskeyManager/Protocols/OIDCAuthenticationHelper.swift:32 | OIDC nonce generation | PASS — cryptographically secure |
| Keychain Services | Costco-Digital/Storage/... | Token storage | PASS |
| CommonCrypto AES-GCM | Costco-Digital/Core/Sources/Core/encryptionDecryption/CryptoManagerImpl.swift | Symmetric encryption | PASS — verify 256-bit key + correct IV handling |
| NokNok PasskeyManager 9.2.0 | PasskeyManagerKit | FIDO2 / WebAuthn | PASS |
| LAContext (LocalAuthentication) | Biometric flows | Face ID / Touch ID gate | VERIFY — bind to keychain key, not just policy success |
| CryptoKit primitives | Where adopted | Modern Swift crypto | CONSIDER — preferred over CommonCrypto for new code |
| arc4random* | AUDIT | Search for non-secure RNG in non-test code | — |
Bind LAContext biometric to a Keychain item
kSecAttrAccessControl set to .biometryCurrentSet. Read the token through the biometric prompt — failure = token unavailable = no authenticated request.let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
[.biometryCurrentSet, .privateKeyUsage],
nil
)
let attributes: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccessControl as String: access!,
kSecAttrService as String: "com.costco.app.token",
kSecValueData as String: tokenData,
]
SecItemAdd(attributes as CFDictionary, nil)Audit IV/nonce handling in CryptoManagerImpl
CryptoManagerImpl generates a fresh IV per encrypt call (12 bytes from SecRandomCopyBytes) and prepends it to ciphertext.encrypt() twice and asserts the ciphertexts differ.3. Secret management — hardcoded keys + URL audit
| Asset type | Pattern observed | Status |
|---|---|---|
| API endpoints | Hardcoded base URLs (e.g. BazaarVoice — see deep dive) | PARTIAL — should be config-driven |
| API keys / secrets in source | Search for "AIza", "AKIA", "sk_live", etc. | SCAN — recommend adding git-secrets pre-commit hook |
| plist API keys | Audit Info.plist files for embedded keys | VERIFY |
| Adobe / Contentstack credentials | Should load from CI secret + xcconfig at build time | VERIFY |
Recommended pattern: xcconfig + CI secrets
// Costco-Production.xcconfig (committed, no secrets)
ADOBE_ENV = production
// Costco-Production.private.xcconfig (gitignored)
ADOBE_LICENSE_KEY = abc123...
CONTENTSTACK_DELIVERY_TOKEN = xyz789...
// Build phase reads from environment in CI; from .private.xcconfig locally
// Info.plist references via $(VAR_NAME)
4. Anti-tampering / jailbreak / anti-debugging
| Control | Status | Note |
|---|---|---|
| Jailbreak detection | ABSENT | No fork/sandbox-escape heuristics |
| Debugger detection (sysctl P_TRACED) | ABSENT | App runs under Frida/lldb undetected |
| App tamper / re-sign detection | PARTIAL | ThreatMetrix risk score covers transactionally |
| App Attest (DeviceCheck) | ABSENT | Apple's hardware-backed attestation — recommended for fraud-sensitive flows |
| Privacy overlay on background | ABSENT | Sensitive screens snapshot in app switcher |
| Screen-recording detection | N/A | UIScreen.main.isCaptured available since iOS 11 |
Privacy overlay on backgrounding
scenePhase != .active for sensitive screens.// SwiftUI
@Environment(\.scenePhase) var scenePhase
var body: some View {
ZStack {
contentView
if scenePhase != .active && isSensitiveScreenVisible {
BlurView()
.overlay(Image(systemName: "lock.fill").font(.largeTitle))
}
}
}
// UIKit
override func applicationWillResignActive(_ application: UIApplication) {
privacyOverlay.show()
}
override func applicationDidBecomeActive(_ application: UIApplication) {
privacyOverlay.hide()
}Adopt App Attest for sensitive flows
DCAppAttestService provides hardware-backed attestation that the app binary is genuine and running on a non-tampered device. Server consumes the assertion and gates payment / member-card display.let service = DCAppAttestService.shared
guard service.isSupported else { return /* fall back */ }
let keyId = try await service.generateKey()
let challenge = try await fetchServerChallenge()
let attestation = try await service.attestKey(keyId, clientDataHash: challenge)
// Send attestation to server; server verifies via AppleJailbreak detection
IOSSecuritySuite) with up-to-date heuristics. Combine with App Attest server-side decision.5. Token & session security
| Asset | Storage | Status |
|---|---|---|
| Access token (JWT) | Keychain (kSecAttrAccessibleWhenUnlockedThisDeviceOnly) | VERIFY ACCESSIBILITY ATTRIBUTE |
| Refresh token | Keychain | VERIFY |
| Membership ID | Storage SPM — verify Keychain vs UserDefaults | REVIEW |
| UserDefaults via Storage abstraction | ENCRYPT or move to Keychain | |
| App Group access | For NSE / Widget / ClickToPay communication | AUDIT — confirm only non-sensitive items shared |
| iCloud Keychain sync | Off for app-specific items (default for ThisDeviceOnly) | PASS |
Use ...ThisDeviceOnly accessibility attributes
ThisDeviceOnly, it can be backed up via iCloud Keychain sync to other devices. For tokens that should not roam, enforce device-only.kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly6. Memory safety — wiping sensitive data
Swift String is immutable; sensitive data should land in [UInt8] or Data with an explicit zeroing step. Pattern:
extension Data {
mutating func secureZero() {
withUnsafeMutableBytes { buf in
guard let baseAddress = buf.baseAddress else { return }
memset_s(baseAddress, buf.count, 0, buf.count)
}
}
}
func authenticate(token: inout Data) async throws {
defer { token.secureZero() }
let request = buildRequest(token: token)
try await network.send(request)
}
Audit auth flow for String-handled tokens
"token: String" / "jwt: String" in auth code; convert to Data with explicit secureZero() in defer.7. Secure logging
30+ print() sites identified in production code. Replace with os.Logger + privacy-aware interpolation:
import os
extension Logger {
static let auth = Logger(subsystem: "com.costco.costco", category: "auth")
}
// Privacy-aware logging
Logger.auth.notice("OIDC token issued for member \(memberId, privacy: .private)")
Logger.auth.error("Token refresh failed: \(error.localizedDescription, privacy: .public)")
Custom SwiftLint rule:
# .swiftlint.yml
custom_rules:
no_print_in_production:
name: "print() in production"
regex: "^\\s*print\\("
severity: warning
excluded:
- ".*Tests.*"
- ".*UITests.*"
8. ATS, certificate pinning & network security
| Control | Status | Note |
|---|---|---|
| NSAppTransportSecurity (main app) | PASS | HTTPS-only enforced |
| NSAllowsArbitraryLoads (NSE Info.plist) | FAIL | Production + QA both have arbitrary loads enabled |
| Cert pinning | PASS | CostcoServerTrustManager — verify pin freshness + backup pin |
| TLS 1.3 minimum | VERIFY | Set tls_protocol_version_t.TLSv13 on URLSessionConfiguration where possible |
| HTTP Strict Transport Security | N/A | iOS WebKit honors HSTS automatically |
Replace NSAllowsArbitraryLoads in NSE
NSAllowsArbitraryLoads = true — bypasses HTTPS-only policy of the main app. Likely needed for an HTTP image host; replace with NSExceptionDomains for that specific host.<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>img-cdn.costco.com</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<false/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict>
</dict>9. Code signing & distribution integrity
| Control | Status |
|---|---|
| Distribution provisioning profile | VERIFY — App Store distribution only |
| Code signing certificate | VERIFY — stored in HSM / Apple Developer keychain |
| Enterprise signing | N/A — should be off for store builds |
| App Transport Security entitlement | PASS if HTTPS-only |
| Hardened Runtime / Notarization | N/A for iOS app |
| Privacy Manifest signed by Apple | REQUIRED 2024+ |
10. Privacy Manifest (PrivacyInfo.xcprivacy)
Apple requires PrivacyInfo.xcprivacy for apps and SDKs accessing Required Reason APIs since 2024. Without it, App Review will reject submissions; SDKs without manifests block your release.
<!-- Costco/Costco/PrivacyInfo.xcprivacy -->
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array><string>CA92.1</string></array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array><string>C617.1</string></array>
</dict>
</array>
<key>NSPrivacyTracking</key>
<false/>
</dict>
</plist>
For each third-party SDK (Adobe, Contentstack, NokNok, ThreatMetrix, SDWebImage, RZVinyl, RZTransitions): confirm the vendor ships PrivacyInfo.xcprivacy. If any don't, the app won't pass App Review.
11. Recommendations summary (prioritized)
| Priority | Action | Effort |
|---|---|---|
| NOW | Remove NSAllowsArbitraryLoads from NSE Info.plist; use NSExceptionDomains | 30 min |
| NOW | Generate PrivacyInfo.xcprivacy | 1 hour |
| NOW | Add privacy overlay on backgrounding for sensitive screens | 2 hours |
| NEXT | Adopt DCAppAttest for sensitive flows | 1-2 weeks |
| NEXT | Bind LAContext biometric to Keychain CryptoObject | 3-5 days |
| NEXT | Move email + sensitive PII to Keychain (off UserDefaults) | 1 day |
| NEXT | Verify Keychain accessibility = ThisDeviceOnly | 30 min |
| NEXT | Replace 30+ print() with os.Logger with privacy interpolation | 1 day |
| LATER | Jailbreak detection via IOSSecuritySuite or similar | 2-3 days |
| LATER | Audit dSYM upload flow + retention | 1 day |
| LATER | SwiftLint rule banning new print() + force-try | 30 min |
| LATER | Memory-safety pass on auth — Data + secureZero | 2-3 days |
| ONGOING | Track NowSecure findings each release; update App Privacy nutrition labels | continuous |