Overview › Code Security · Obfuscation · Crypto (Android)

Code Security · Obfuscation · Crypto (Android)

Generated 2026-05-11 · Costco Android

Executive summary

Solid baseline: R8 + resource shrinking enabled in release, AES-256-GCM via Android Keystore for token encryption, BiometricPrompt for member-card unlock, NowSecure scans in CI. Notable gaps: API keys committed in gradle.properties, no Play Integrity / SafetyNet, no root detection, FLAG_SECURE absent on payment screens, R8 keep-rules likely over-broad (Hilt/Room/Retrofit annotations require careful exclusions), and one new Random() use in a non-test path.

1. Code obfuscation — R8 / ProGuard

Configuration today

SettingValueStatus
minifyEnabled (release)truePASS
shrinkResources (release)truePASS
ProGuard rules fileCostco/proguard-rules.pro + proguardConfigs/shrink-obfuscate.txtREVIEW
Test ProGuard rulesCostco/test-progaurd-rules.proFILENAME TYPO
Per-module consumer-rules.proPresent in feature/* and shared/*PASS
R8 modeDefault (full mode in AGP 8+)PASS

Common R8 keep-rule gotchas to verify

R8 in full mode is aggressive. The following classes / patterns must be kept or your release builds break in subtle ways:

# Hilt-generated code
-keep class dagger.hilt.** { *; }
-keep class * extends dagger.hilt.android.internal.lifecycle.HiltViewModelFactory { *; }

# Retrofit + Moshi/kotlinx-serialization
-keepattributes Signature
-keepattributes Exceptions
-keepclasseswithmembers,allowshrinking,allowobfuscation interface * {
    @retrofit2.http.* <methods>;
}
# kotlinx-serialization adapter requires @Serializable types kept
-keepclasseswithmembers class * {
    @kotlinx.serialization.Serializable <methods>;
}

# Room — entities + DAOs cannot be obfuscated
-keep class * extends androidx.room.RoomDatabase
-keep @androidx.room.Entity class *
-keep @androidx.room.Dao interface *

# Glide / Coil — modules and registered components
-keep public class * implements com.bumptech.glide.module.GlideModule

# Firebase Crashlytics — symbolication needs un-mangled stack frames
-keepattributes SourceFile,LineNumberTable
-keep class com.google.firebase.crashlytics.** { *; }

# WebView JS interface methods
-keepclassmembers class * {
    @android.webkit.JavascriptInterface <methods>;
}

Findings

MEDIUM

Verify R8 doesn't strip Hilt-generated factories

If a release build produces NoSuchFieldException: dagger.hilt.android.internal.modules, an R8 rule has gone too far.
Recommendation: Run ./gradlew :Costco:assembleRelease + smoke test on every release. Capture R8's configuration.txt + seeds.txt in CI artifacts so any keep-rule diff is reviewable.
MEDIUM

Crashlytics symbolication

-keepattributes SourceFile,LineNumberTable + the Crashlytics gradle plugin upload mapping file. Verify crashlytics.uploadMappingFileEnabled = true in release flavor.
Recommendation: Also upload the mapping.txt as a build artifact for retention beyond Crashlytics' window.
LOW

Filename typo

test-progaurd-rules.pro — typo (progaurd → proguard). Cosmetic but shows up in every grep.
Recommendation: Rename + update Gradle reference.

2. Cryptographic primitives — what's used, where

Inventory of crypto-relevant code paths identified in static review:

PrimitiveFilePurposeStatus
AES-256-GCM (AndroidKeyStore)shared/storage/.../KeyStoreManagerImpl.ktToken encryptionPASS — recommended algorithm + hardware-backed key
BouncyCastle 1.70libs catalogJWT signing / verificationPASS — well-maintained version
Nimbus JOSE+JWT 10.8libs catalogJWT handlingPASS
NokNok FIDO2 9.2.0shared/authPasskey / WebAuthnPASS
BiometricPromptCostco/.../BiometricUtilImpl.javaMember card unlockVERIFY — confirm bound to keystore CryptoObject
java.util.RandomCostco/.../VolleyManagerImpl.java:88Likely jitter / retry — should be SecureRandom for any token useREVIEW
EncryptDecryptUtilsshared/storage/.../EncryptDecryptUtils.ktWraps AES-GCMPASS — verify key rotation policy is documented

Findings

HIGH

BiometricPrompt should be bound to a keystore-backed key

A BiometricPrompt that doesn't decrypt a key on success is a "user-presence" check, not authentication — easily bypassed if the device is rooted or by replaying the success callback.
Recommendation: Require setAuthenticationParameters(BIOMETRIC_STRONG) + bind to a Keystore-resident SecretKey with setUserAuthenticationRequired(true). The biometric success unlocks a CryptoObject; cryptographic operations on protected data fail without a successful biometric.
MEDIUM

new Random() in production path

VolleyManagerImpl.java:88 uses new Random(). Even if it's just retry jitter, the cumulative effect is that the codebase has a non-secure RNG visible — if anyone copies the pattern for a nonce/token, you have a real bug.
Recommendation: Replace with java.security.SecureRandom. Add a Detekt custom rule banning new Random().
MEDIUM

Document key rotation policy

Hardware-backed keys are great until you need to rotate them. Without a documented rotation strategy, you can't respond to a key compromise.
Recommendation: Document: keystore alias scheme, rotation cadence, migration path for re-encrypting persisted ciphertext when a new key is generated.

3. Secret management — hardcoded keys audit

The Maps API key problem

CRITICAL

Google Maps API key committed in gradle.properties

A real API key sits in a committed file. Even if Maps API key restrictions (Android package + SHA-1) limit abuse, this fails most security-audit checklists.
Recommendation: Move to local.properties (gitignored) or a CI secret. Read at build time via Properties. Restrict the key in Google Cloud Console to the production package + production SHA-1; have a separate key for debug.
// local.properties (gitignored)
MAPS_API_KEY=AIza...

// Costco/build.gradle
android {
    defaultConfig {
        def localProps = new Properties()
        rootProject.file("local.properties").withInputStream { localProps.load(it) }
        manifestPlaceholders = [MAPS_API_KEY: localProps['MAPS_API_KEY'] ?: ""]
    }
}

<!-- AndroidManifest.xml -->
<meta-data
    android:name="com.google.android.geo.API_KEY"
    android:value="${MAPS_API_KEY}"/>

Other hardcoded URL audit

CategoryLandingAnalyticsHelperImpl.kt:189 and CategoryLandingViewModel.kt:600 hardcode https://www.costco.com / .ca. Not a secret, but configuration that should come from BuildConfig or remote config.

4. Anti-tampering / anti-debugging / root detection

ControlStatusNote
Play Integrity APIABSENTNo IntegrityManager imports — recommended for fraud-sensitive flows
SafetyNet (legacy)N/ADeprecated; Play Integrity is the replacement
Root detection (RootBeer / custom)ABSENTApp doesn't detect rooted devices
Debugger detectionN/Aandroid:debuggable="false" in release manifest, but no runtime check
Native code obfuscationN/AApp ships ThreatMetrix native libs — verify those are stripped
FLAG_SECURE on sensitive screensABSENTMembership card / DMC / payment screens not protected from screenshot/recents
App tamper detectionPARTIALThreatMetrix provides risk score; no app-self integrity
HIGH

Adopt Play Integrity API for sensitive flows

Play Integrity verdicts (MEETS_DEVICE_INTEGRITY, MEETS_BASIC_INTEGRITY, MEETS_STRONG_INTEGRITY) feed into your fraud / authentication backend. For a retail app handling payments, this is table-stakes.
Recommendation: Wire Play Integrity into the auth flow. Server uses the verdict; on MEETS_DEVICE_INTEGRITY = false, deny sensitive operations (payment, member-card display) or step up authentication.
HIGH

Add FLAG_SECURE on sensitive screens

Without FLAG_SECURE, recent-apps screenshots include the membership barcode. Anyone with physical access can lift it from the carousel.
// MainActivity / each sensitive Activity
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    if (isSensitiveScreen()) {
        window.setFlags(
            WindowManager.LayoutParams.FLAG_SECURE,
            WindowManager.LayoutParams.FLAG_SECURE
        )
    }
}
Recommendation: Apply to membership card, DMC wallet cards, payment-related WebViews.
MEDIUM

Root detection via Play Integrity verdict

Don't roll your own (RootBeer is bypassed easily). Use Play Integrity's MEETS_DEVICE_INTEGRITY verdict server-side. The verdict change is also a fraud signal worth feeding into ThreatMetrix.

5. Token & session security

AssetStorageStatus
Access token (JWT)EncryptDecryptUtils → DataStore (AES-256-GCM Keystore)PASS
Refresh tokenSame as abovePASS
Membership IDSharedMembershipService → DataStoreVERIFY ENCRYPTION
EmailDataStore (plain) — F— flagged earlierENCRYPT
Cached lat/lngDataStore (plain)CONSIDER ENCRYPTING
Device ID for analyticsVolatile — re-fetched per sessionPASS
Token-revocation hookLogout flow — VERIFY all tokens cleared
MEDIUM

Logout token cleanup

Confirm logout zeroes: access token, refresh token, biometric-bound key, cached PII, and revokes the session server-side.

6. Memory safety — wiping sensitive data

Java/Kotlin Strings are immutable and live until garbage collection. After decryption, sensitive data should land in char[] or byte[] and be zeroed when done. Sample of patterns to use:

// Better than String for passwords/tokens
fun authenticate(username: String, password: CharArray): Result {
    try {
        return doAuthenticate(username, password)
    } finally {
        Arrays.fill(password, ' ')  // zero the array
    }
}

// For byte arrays
val tokenBytes = decryptToken()
try {
    network.send(buildRequest(tokenBytes))
} finally {
    java.util.Arrays.fill(tokenBytes, 0.toByte())
}

Recommendation: Audit the auth flow for String usage of decrypted tokens; convert to CharArray + zero in finally.

7. Secure logging

The Lint Report identifies 231 Log.* calls and 24 print/Log.d sites in production code. Without a release-build filter, these can leak PII to logcat — accessible to any app with diagnostic entitlements (or ADB on USB-debug devices).

// Application.onCreate — install a release tree that scrubs and forwards to Crashlytics
class CostcoApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        if (BuildConfig.DEBUG) {
            Timber.plant(Timber.DebugTree())
        } else {
            Timber.plant(ReleaseTree())
        }
    }
}

class ReleaseTree : Timber.Tree() {
    override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
        if (priority < Log.WARN) return  // drop verbose/debug/info entirely
        val scrubbed = scrubPii(message)
        if (t != null) {
            FirebaseCrashlytics.getInstance().recordException(t)
        }
        FirebaseCrashlytics.getInstance().log("${'$'}{tag ?: ""}: ${'$'}scrubbed")
    }

    private fun scrubPii(s: String): String =
        s.replace(EMAIL_REGEX, "[email]")
         .replace(MEMBER_ID_REGEX, "[member]")
         .replace(PHONE_REGEX, "[phone]")
}

8. Code signing & distribution integrity

ControlStatus
Release APK / Bundle signed with v2/v3 signature schemeVERIFY
Play App Signing enrolledVERIFY (recommended for upload-key separation)
Signing keys stored securelyVERIFY (HSM / Cloud KMS / Vault)
Release builds run minifiedPASS
Reproducible buildsN/A for typical Android — Play App Signing covers integrity

9. Recommendations summary (prioritized)

PriorityActionEffort
NOWMove Maps API key out of gradle.properties1 hour
NOWAdd FLAG_SECURE to membership / payment screens2 hours
NEXTAdopt Play Integrity for sensitive flows; gate payments / member-card on verdict1-2 weeks
NEXTBiometricPrompt → CryptoObject binding3-5 days
NEXTEncrypt email + lat/lng at rest1 day
NEXTReplace new Random() with SecureRandom; add Detekt rule30 min
LATERReleaseTree with PII scrubbing + Crashlytics forwarding1 day
LATERDocument key rotation policy; capture R8 mapping in CI artifacts1 day
LATERMemory-safety pass on auth flow — convert String → CharArray for sensitive data2-3 days
ONGOINGRe-baseline NowSecure findings each release; maintain signed CI artifact retentioncontinuous
Costco Android · Code Review Report · Generated 2026-05-11 · 626 machine-curated findings