@mmerterden/multi-agent-pipeline 20.1.0 → 20.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/docs/facts.json +5 -5
- package/manifest.json +52 -31
- package/package.json +1 -1
- package/pipeline/skills/.skill-manifest.json +36 -20
- package/pipeline/skills/.skills-index.json +75 -9
- package/pipeline/skills/shared/README.md +13 -7
- package/pipeline/skills/shared/external/android-architecture/SKILL.md +71 -0
- package/pipeline/skills/shared/external/android-architecture/references/patterns.md +142 -0
- package/pipeline/skills/shared/external/android-build-quality-gates/SKILL.md +314 -0
- package/pipeline/skills/shared/external/android-build-quality-gates/references/patterns.md +432 -0
- package/pipeline/skills/shared/external/android-datastore/SKILL.md +236 -0
- package/pipeline/skills/shared/external/android-datastore/references/patterns.md +297 -0
- package/pipeline/skills/shared/external/android-design-tokens-codegen/SKILL.md +249 -0
- package/pipeline/skills/shared/external/android-design-tokens-codegen/references/patterns.md +270 -0
- package/pipeline/skills/shared/external/android-jetpack-compose-expert/SKILL.md +62 -0
- package/pipeline/skills/shared/external/android-mvi-viewmodel/SKILL.md +255 -0
- package/pipeline/skills/shared/external/android-mvi-viewmodel/references/patterns.md +257 -0
- package/pipeline/skills/shared/external/android-performance/SKILL.md +86 -602
- package/pipeline/skills/shared/external/android-performance/references/patterns.md +659 -0
- package/pipeline/skills/shared/external/android-security/SKILL.md +117 -430
- package/pipeline/skills/shared/external/android-security/references/patterns.md +690 -0
- package/pipeline/skills/shared/external/{android_ui_verification → android-ui-verification}/SKILL.md +1 -1
- package/pipeline/skills/shared/external/api-security-best-practices/SKILL.md +35 -733
- package/pipeline/skills/shared/external/api-security-best-practices/references/auth.md +299 -0
- package/pipeline/skills/shared/external/api-security-best-practices/references/input-validation.md +255 -0
- package/pipeline/skills/shared/external/api-security-best-practices/references/rate-limiting.md +167 -0
- package/pipeline/skills/shared/external/app-intents/SKILL.md +39 -174
- package/pipeline/skills/shared/external/app-intents/references/appintents-advanced.md +178 -0
- package/pipeline/skills/shared/external/compose-components/SKILL.md +48 -0
- package/pipeline/skills/shared/external/compose-components/references/patterns.md +200 -0
- package/pipeline/skills/shared/external/compose-navigation/SKILL.md +66 -3
- package/pipeline/skills/shared/external/compose-navigation/references/patterns.md +191 -0
- package/pipeline/skills/shared/external/compose-testing/SKILL.md +107 -397
- package/pipeline/skills/shared/external/compose-testing/references/patterns.md +631 -0
- package/pipeline/skills/shared/external/gradle-kotlin-dsl/SKILL.md +121 -449
- package/pipeline/skills/shared/external/gradle-kotlin-dsl/references/patterns.md +715 -0
- package/pipeline/skills/shared/external/kotlin-coroutines-expert/SKILL.md +143 -0
- package/pipeline/skills/shared/external/mapkit-location/SKILL.md +27 -102
- package/pipeline/skills/shared/external/mapkit-location/references/mapkit-patterns.md +42 -0
- package/pipeline/skills/shared/external/retrofit-networking/SKILL.md +94 -383
- package/pipeline/skills/shared/external/retrofit-networking/references/patterns.md +640 -0
- package/pipeline/skills/shared/external/room-database/SKILL.md +101 -440
- package/pipeline/skills/shared/external/room-database/references/patterns.md +614 -0
- package/pipeline/skills/shared/external/storekit/SKILL.md +69 -343
- package/pipeline/skills/shared/external/storekit/references/core-patterns.md +371 -0
- package/pipeline/skills/shared/external/widgetkit/SKILL.md +25 -101
- package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +107 -0
- package/pipeline/skills/skills-index.md +8 -2
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
# Android Security -- Reference Patterns
|
|
2
|
+
|
|
3
|
+
Full code for the patterns summarized in `SKILL.md`. Load the section you need
|
|
4
|
+
when implementing that specific security feature.
|
|
5
|
+
|
|
6
|
+
## Contents
|
|
7
|
+
|
|
8
|
+
- [Secure Data Storage](#secure-data-storage)
|
|
9
|
+
- [Encrypted DataStore](#encrypted-datastore)
|
|
10
|
+
- [Database Key Hierarchy](#database-key-hierarchy)
|
|
11
|
+
- [Build-Type Security Gating](#build-type-security-gating)
|
|
12
|
+
- [Biometric Authentication](#biometric-authentication)
|
|
13
|
+
- [Certificate Pinning](#certificate-pinning)
|
|
14
|
+
- [API Key Protection](#api-key-protection)
|
|
15
|
+
- [R8 Obfuscation](#r8-obfuscation)
|
|
16
|
+
- [Play Integrity API](#play-integrity-api)
|
|
17
|
+
- [Root Detection](#root-detection)
|
|
18
|
+
- [Secure Network Communication](#secure-network-communication)
|
|
19
|
+
- [Secure Coding Patterns](#secure-coding-patterns)
|
|
20
|
+
|
|
21
|
+
## Secure Data Storage
|
|
22
|
+
|
|
23
|
+
### EncryptedSharedPreferences (legacy)
|
|
24
|
+
|
|
25
|
+
The AndroidX Security `EncryptedSharedPreferences` API is deprecated. Keep the
|
|
26
|
+
snippet below only for reading an existing store during migration; write new
|
|
27
|
+
key-value secrets through [Encrypted DataStore](#encrypted-datastore). It uses
|
|
28
|
+
AES-256 encryption under the hood.
|
|
29
|
+
|
|
30
|
+
```kotlin
|
|
31
|
+
import androidx.security.crypto.EncryptedSharedPreferences
|
|
32
|
+
import androidx.security.crypto.MasterKey
|
|
33
|
+
|
|
34
|
+
class SecureStorage @Inject constructor(
|
|
35
|
+
@ApplicationContext private val context: Context,
|
|
36
|
+
) {
|
|
37
|
+
private val masterKey = MasterKey.Builder(context)
|
|
38
|
+
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
|
39
|
+
.build()
|
|
40
|
+
|
|
41
|
+
private val prefs = EncryptedSharedPreferences.create(
|
|
42
|
+
context,
|
|
43
|
+
"secure_prefs",
|
|
44
|
+
masterKey,
|
|
45
|
+
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
|
46
|
+
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
fun saveToken(token: String) {
|
|
50
|
+
prefs.edit().putString("auth_token", token).apply()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
fun getToken(): String? = prefs.getString("auth_token", null)
|
|
54
|
+
|
|
55
|
+
fun clearToken() {
|
|
56
|
+
prefs.edit().remove("auth_token").apply()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
fun clearAll() {
|
|
60
|
+
prefs.edit().clear().apply()
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Android Keystore
|
|
66
|
+
|
|
67
|
+
For cryptographic key management. Keys never leave the hardware.
|
|
68
|
+
|
|
69
|
+
```kotlin
|
|
70
|
+
import java.security.KeyStore
|
|
71
|
+
import javax.crypto.Cipher
|
|
72
|
+
import javax.crypto.KeyGenerator
|
|
73
|
+
import javax.crypto.SecretKey
|
|
74
|
+
import javax.crypto.spec.GCMParameterSpec
|
|
75
|
+
import android.security.keystore.KeyGenParameterSpec
|
|
76
|
+
import android.security.keystore.KeyProperties
|
|
77
|
+
|
|
78
|
+
class KeystoreManager {
|
|
79
|
+
|
|
80
|
+
companion object {
|
|
81
|
+
private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
|
|
82
|
+
private const val KEY_ALIAS = "app_encryption_key"
|
|
83
|
+
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private fun getOrCreateKey(): SecretKey {
|
|
87
|
+
val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) }
|
|
88
|
+
|
|
89
|
+
keyStore.getEntry(KEY_ALIAS, null)?.let { entry ->
|
|
90
|
+
return (entry as KeyStore.SecretKeyEntry).secretKey
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
val keyGenerator = KeyGenerator.getInstance(
|
|
94
|
+
KeyProperties.KEY_ALGORITHM_AES,
|
|
95
|
+
KEYSTORE_PROVIDER,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
keyGenerator.init(
|
|
99
|
+
KeyGenParameterSpec.Builder(
|
|
100
|
+
KEY_ALIAS,
|
|
101
|
+
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
|
102
|
+
)
|
|
103
|
+
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
|
104
|
+
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
|
105
|
+
.setKeySize(256)
|
|
106
|
+
.setUserAuthenticationRequired(false)
|
|
107
|
+
.build()
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return keyGenerator.generateKey()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
fun encrypt(data: ByteArray): Pair<ByteArray, ByteArray> {
|
|
114
|
+
val cipher = Cipher.getInstance(TRANSFORMATION)
|
|
115
|
+
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
|
|
116
|
+
val encrypted = cipher.doFinal(data)
|
|
117
|
+
return Pair(cipher.iv, encrypted)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
fun decrypt(iv: ByteArray, encryptedData: ByteArray): ByteArray {
|
|
121
|
+
val cipher = Cipher.getInstance(TRANSFORMATION)
|
|
122
|
+
val spec = GCMParameterSpec(128, iv)
|
|
123
|
+
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), spec)
|
|
124
|
+
return cipher.doFinal(encryptedData)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Encrypted DataStore
|
|
130
|
+
|
|
131
|
+
`EncryptedSharedPreferences` is deprecated. For typed key-value secrets, encrypt
|
|
132
|
+
at the DataStore serializer layer: the delegate serializer produces plaintext
|
|
133
|
+
bytes, a Keystore-held AES/GCM key encrypts them, and only ciphertext (IV
|
|
134
|
+
prepended) reaches disk. The DataStore wiring itself (the `dataStore` delegate,
|
|
135
|
+
`Flow` reads, `updateData` writes) is not repeated here; see the
|
|
136
|
+
`android-datastore` skill for it. This serializer plugs into that setup.
|
|
137
|
+
|
|
138
|
+
```kotlin
|
|
139
|
+
class KeystoreCrypto(private val keystore: KeystoreManager) {
|
|
140
|
+
fun seal(plaintext: ByteArray): ByteArray {
|
|
141
|
+
val (iv, ciphertext) = keystore.encrypt(plaintext)
|
|
142
|
+
return ByteArray(1 + iv.size + ciphertext.size).also {
|
|
143
|
+
it[0] = iv.size.toByte()
|
|
144
|
+
iv.copyInto(it, destinationOffset = 1)
|
|
145
|
+
ciphertext.copyInto(it, destinationOffset = 1 + iv.size)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
fun open(sealed: ByteArray): ByteArray {
|
|
150
|
+
val ivSize = sealed[0].toInt()
|
|
151
|
+
val iv = sealed.copyOfRange(1, 1 + ivSize)
|
|
152
|
+
val ciphertext = sealed.copyOfRange(1 + ivSize, sealed.size)
|
|
153
|
+
return keystore.decrypt(iv, ciphertext)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
class EncryptedSerializer<T>(
|
|
158
|
+
private val delegate: Serializer<T>,
|
|
159
|
+
private val crypto: KeystoreCrypto,
|
|
160
|
+
) : Serializer<T> {
|
|
161
|
+
|
|
162
|
+
override val defaultValue: T = delegate.defaultValue
|
|
163
|
+
|
|
164
|
+
override suspend fun readFrom(input: InputStream): T {
|
|
165
|
+
val sealed = input.readBytes()
|
|
166
|
+
if (sealed.isEmpty()) return defaultValue
|
|
167
|
+
val plaintext = crypto.open(sealed)
|
|
168
|
+
return delegate.readFrom(ByteArrayInputStream(plaintext))
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
override suspend fun writeTo(t: T, output: OutputStream) {
|
|
172
|
+
val buffer = ByteArrayOutputStream()
|
|
173
|
+
delegate.writeTo(t, buffer)
|
|
174
|
+
output.write(crypto.seal(buffer.toByteArray()))
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Database Key Hierarchy
|
|
180
|
+
|
|
181
|
+
Envelope encryption for a database passphrase (for example an encrypted SQLCipher
|
|
182
|
+
database). A random 32-byte data key encrypts the database; a non-exportable
|
|
183
|
+
Keystore AES/GCM key wraps that data key. Only the wrapped key plus its IV are
|
|
184
|
+
persisted. The plaintext data key is decrypted just in time and its bytes are
|
|
185
|
+
zeroed after the database opens. The SQLCipher `SupportFactory` wiring that
|
|
186
|
+
consumes the passphrase lives in the `room-database` skill.
|
|
187
|
+
|
|
188
|
+
```kotlin
|
|
189
|
+
interface WrappedKeyStore {
|
|
190
|
+
fun read(): Pair<ByteArray, ByteArray>?
|
|
191
|
+
fun write(wrapped: ByteArray, iv: ByteArray)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
class DatabaseKeyProvider(private val store: WrappedKeyStore) {
|
|
195
|
+
|
|
196
|
+
private companion object {
|
|
197
|
+
const val WRAPPING_KEY_ALIAS = "db_key_wrapping_key"
|
|
198
|
+
const val TRANSFORMATION = "AES/GCM/NoPadding"
|
|
199
|
+
const val DATA_KEY_SIZE_BYTES = 32
|
|
200
|
+
const val GCM_TAG_BITS = 128
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
fun loadOrCreateDataKey(): ByteArray {
|
|
204
|
+
store.read()?.let { (wrapped, iv) -> return unwrap(wrapped, iv) }
|
|
205
|
+
val dataKey = ByteArray(DATA_KEY_SIZE_BYTES).also { SecureRandom().nextBytes(it) }
|
|
206
|
+
val (wrapped, iv) = wrap(dataKey)
|
|
207
|
+
store.write(wrapped, iv)
|
|
208
|
+
return dataKey
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private fun wrap(dataKey: ByteArray): Pair<ByteArray, ByteArray> {
|
|
212
|
+
val cipher = Cipher.getInstance(TRANSFORMATION)
|
|
213
|
+
cipher.init(Cipher.ENCRYPT_MODE, wrappingKey())
|
|
214
|
+
return cipher.doFinal(dataKey) to cipher.iv
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private fun unwrap(wrapped: ByteArray, iv: ByteArray): ByteArray {
|
|
218
|
+
val cipher = Cipher.getInstance(TRANSFORMATION)
|
|
219
|
+
cipher.init(Cipher.DECRYPT_MODE, wrappingKey(), GCMParameterSpec(GCM_TAG_BITS, iv))
|
|
220
|
+
return cipher.doFinal(wrapped)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private fun wrappingKey(): SecretKey {
|
|
224
|
+
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
|
225
|
+
(keyStore.getEntry(WRAPPING_KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)
|
|
226
|
+
?.let { return it.secretKey }
|
|
227
|
+
|
|
228
|
+
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").apply {
|
|
229
|
+
init(
|
|
230
|
+
KeyGenParameterSpec.Builder(
|
|
231
|
+
WRAPPING_KEY_ALIAS,
|
|
232
|
+
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
|
233
|
+
)
|
|
234
|
+
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
|
235
|
+
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
|
236
|
+
.setKeySize(256)
|
|
237
|
+
.build()
|
|
238
|
+
)
|
|
239
|
+
}.generateKey()
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Decrypt just in time and zero the plaintext key bytes once the database owns them:
|
|
245
|
+
|
|
246
|
+
```kotlin
|
|
247
|
+
val dataKey = keyProvider.loadOrCreateDataKey()
|
|
248
|
+
try {
|
|
249
|
+
openEncryptedDatabase(passphrase = dataKey) // room-database: SupportFactory wiring
|
|
250
|
+
} finally {
|
|
251
|
+
dataKey.fill(0)
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
## Build-Type Security Gating
|
|
256
|
+
|
|
257
|
+
Certificate pinning and any traffic inspector are decided in one place, by build
|
|
258
|
+
type. Pinning is production-only (a debug proxy would fail the pins), and the
|
|
259
|
+
inspection and logging interceptors are debug-only and added last so they observe
|
|
260
|
+
the final, fully-decorated request. Bind the debug interceptor set from a module
|
|
261
|
+
in the `src/debug` source set and an empty set from `src/release`, so release
|
|
262
|
+
builds cannot even link the inspector.
|
|
263
|
+
|
|
264
|
+
```kotlin
|
|
265
|
+
// src/main -- assembly, no build-type branching in the class body
|
|
266
|
+
class OkHttpClientFactory @Inject constructor(
|
|
267
|
+
private val authInterceptor: AuthInterceptor,
|
|
268
|
+
private val certificatePinner: CertificatePinner,
|
|
269
|
+
private val observers: Set<@JvmSuppressWildcards Interceptor>,
|
|
270
|
+
) {
|
|
271
|
+
fun create(): OkHttpClient = OkHttpClient.Builder()
|
|
272
|
+
.certificatePinner(certificatePinner)
|
|
273
|
+
.addInterceptor(authInterceptor)
|
|
274
|
+
.apply { observers.forEach(::addInterceptor) } // logging/inspection, added last
|
|
275
|
+
.build()
|
|
276
|
+
}
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
```kotlin
|
|
280
|
+
// src/release -- production pins, no observers
|
|
281
|
+
@Module
|
|
282
|
+
@InstallIn(SingletonComponent::class)
|
|
283
|
+
object ReleaseNetworkModule {
|
|
284
|
+
|
|
285
|
+
@Provides
|
|
286
|
+
fun certificatePinner(): CertificatePinner = CertificatePinner.Builder()
|
|
287
|
+
.add("api.example.com", "sha256/AAAA...=")
|
|
288
|
+
.add("api.example.com", "sha256/BBBB...=")
|
|
289
|
+
.build()
|
|
290
|
+
|
|
291
|
+
@Provides
|
|
292
|
+
@ElementsIntoSet
|
|
293
|
+
fun observers(): Set<Interceptor> = emptySet()
|
|
294
|
+
}
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
```kotlin
|
|
298
|
+
// src/debug -- no pins, inspector + logging observers
|
|
299
|
+
@Module
|
|
300
|
+
@InstallIn(SingletonComponent::class)
|
|
301
|
+
object DebugNetworkModule {
|
|
302
|
+
|
|
303
|
+
@Provides
|
|
304
|
+
fun certificatePinner(): CertificatePinner = CertificatePinner.Builder().build()
|
|
305
|
+
|
|
306
|
+
@Provides
|
|
307
|
+
@IntoSet
|
|
308
|
+
fun logging(): Interceptor = HttpLoggingInterceptor()
|
|
309
|
+
.apply { level = HttpLoggingInterceptor.Level.BODY }
|
|
310
|
+
}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
## Biometric Authentication
|
|
314
|
+
|
|
315
|
+
### BiometricPrompt Setup
|
|
316
|
+
|
|
317
|
+
```kotlin
|
|
318
|
+
class BiometricAuthenticator @Inject constructor() {
|
|
319
|
+
|
|
320
|
+
fun authenticate(
|
|
321
|
+
activity: FragmentActivity,
|
|
322
|
+
title: String = "Authenticate",
|
|
323
|
+
subtitle: String = "Verify your identity",
|
|
324
|
+
onSuccess: () -> Unit,
|
|
325
|
+
onError: (String) -> Unit,
|
|
326
|
+
) {
|
|
327
|
+
val biometricManager = BiometricManager.from(activity)
|
|
328
|
+
val canAuthenticate = biometricManager.canAuthenticate(
|
|
329
|
+
BiometricManager.Authenticators.BIOMETRIC_STRONG or
|
|
330
|
+
BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
if (canAuthenticate != BiometricManager.BIOMETRIC_SUCCESS) {
|
|
334
|
+
onError(mapBiometricError(canAuthenticate))
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
|
339
|
+
.setTitle(title)
|
|
340
|
+
.setSubtitle(subtitle)
|
|
341
|
+
.setAllowedAuthenticators(
|
|
342
|
+
BiometricManager.Authenticators.BIOMETRIC_STRONG or
|
|
343
|
+
BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
|
344
|
+
)
|
|
345
|
+
.build()
|
|
346
|
+
|
|
347
|
+
val callback = object : BiometricPrompt.AuthenticationCallback() {
|
|
348
|
+
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
|
349
|
+
onSuccess()
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
|
353
|
+
onError(errString.toString())
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
override fun onAuthenticationFailed() {
|
|
357
|
+
// Called on failed attempt but user can retry
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
val prompt = BiometricPrompt(activity, callback)
|
|
362
|
+
prompt.authenticate(promptInfo)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private fun mapBiometricError(code: Int): String = when (code) {
|
|
366
|
+
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> "No biometric hardware"
|
|
367
|
+
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> "Biometric hardware unavailable"
|
|
368
|
+
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> "No biometrics enrolled"
|
|
369
|
+
else -> "Biometric authentication unavailable"
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
### Biometric + Keystore (Crypto-Based Auth)
|
|
375
|
+
|
|
376
|
+
```kotlin
|
|
377
|
+
fun authenticateWithCrypto(
|
|
378
|
+
activity: FragmentActivity,
|
|
379
|
+
onSuccess: (Cipher) -> Unit,
|
|
380
|
+
) {
|
|
381
|
+
val key = getOrCreateBiometricKey()
|
|
382
|
+
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
|
383
|
+
cipher.init(Cipher.ENCRYPT_MODE, key)
|
|
384
|
+
|
|
385
|
+
val cryptoObject = BiometricPrompt.CryptoObject(cipher)
|
|
386
|
+
|
|
387
|
+
val prompt = BiometricPrompt(activity, object : BiometricPrompt.AuthenticationCallback() {
|
|
388
|
+
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
|
389
|
+
result.cryptoObject?.cipher?.let(onSuccess)
|
|
390
|
+
}
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
prompt.authenticate(promptInfo, cryptoObject)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private fun getOrCreateBiometricKey(): SecretKey {
|
|
397
|
+
val keyGenerator = KeyGenerator.getInstance("AES", "AndroidKeyStore")
|
|
398
|
+
keyGenerator.init(
|
|
399
|
+
KeyGenParameterSpec.Builder("biometric_key",
|
|
400
|
+
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
|
401
|
+
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
|
402
|
+
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
|
403
|
+
.setUserAuthenticationRequired(true)
|
|
404
|
+
.setInvalidatedByBiometricEnrollment(true)
|
|
405
|
+
.build()
|
|
406
|
+
)
|
|
407
|
+
return keyGenerator.generateKey()
|
|
408
|
+
}
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
## Certificate Pinning
|
|
412
|
+
|
|
413
|
+
### OkHttp CertificatePinner
|
|
414
|
+
|
|
415
|
+
```kotlin
|
|
416
|
+
val certificatePinner = CertificatePinner.Builder()
|
|
417
|
+
.add("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") // Primary
|
|
418
|
+
.add("api.example.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=") // Backup
|
|
419
|
+
.build()
|
|
420
|
+
|
|
421
|
+
val client = OkHttpClient.Builder()
|
|
422
|
+
.certificatePinner(certificatePinner)
|
|
423
|
+
.build()
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
### Generating Pin Hashes
|
|
427
|
+
|
|
428
|
+
```bash
|
|
429
|
+
# From a domain
|
|
430
|
+
openssl s_client -connect api.example.com:443 -servername api.example.com \
|
|
431
|
+
| openssl x509 -pubkey -noout \
|
|
432
|
+
| openssl pkey -pubin -outform der \
|
|
433
|
+
| openssl dgst -sha256 -binary \
|
|
434
|
+
| openssl enc -base64
|
|
435
|
+
|
|
436
|
+
# From a certificate file
|
|
437
|
+
openssl x509 -in cert.pem -pubkey -noout \
|
|
438
|
+
| openssl pkey -pubin -outform der \
|
|
439
|
+
| openssl dgst -sha256 -binary \
|
|
440
|
+
| openssl enc -base64
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
### Network Security Config (XML-based)
|
|
444
|
+
|
|
445
|
+
```xml
|
|
446
|
+
<!-- res/xml/network_security_config.xml -->
|
|
447
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
448
|
+
<network-security-config>
|
|
449
|
+
<domain-config cleartextTrafficPermitted="false">
|
|
450
|
+
<domain includeSubdomains="true">api.example.com</domain>
|
|
451
|
+
<pin-set expiration="2026-01-01">
|
|
452
|
+
<pin digest="SHA-256">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</pin>
|
|
453
|
+
<pin digest="SHA-256">BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=</pin>
|
|
454
|
+
</pin-set>
|
|
455
|
+
</domain-config>
|
|
456
|
+
|
|
457
|
+
<!-- Block cleartext for all domains -->
|
|
458
|
+
<base-config cleartextTrafficPermitted="false" />
|
|
459
|
+
</network-security-config>
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
```xml
|
|
463
|
+
<!-- AndroidManifest.xml -->
|
|
464
|
+
<application android:networkSecurityConfig="@xml/network_security_config">
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
## API Key Protection
|
|
468
|
+
|
|
469
|
+
### local.properties (Gitignored)
|
|
470
|
+
|
|
471
|
+
```properties
|
|
472
|
+
# local.properties -- NEVER commit this file
|
|
473
|
+
MAPS_API_KEY=AIzaSyB...
|
|
474
|
+
BASE_URL=https://api.example.com/
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
### Build-Time Injection
|
|
478
|
+
|
|
479
|
+
```kotlin
|
|
480
|
+
// build.gradle.kts
|
|
481
|
+
import java.util.Properties
|
|
482
|
+
|
|
483
|
+
val localProperties = Properties().apply {
|
|
484
|
+
val file = rootProject.file("local.properties")
|
|
485
|
+
if (file.exists()) load(file.inputStream())
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
android {
|
|
489
|
+
defaultConfig {
|
|
490
|
+
buildConfigField("String", "MAPS_API_KEY",
|
|
491
|
+
"\"${localProperties["MAPS_API_KEY"] ?: ""}\"")
|
|
492
|
+
buildConfigField("String", "BASE_URL",
|
|
493
|
+
"\"${localProperties["BASE_URL"] ?: "https://api.example.com/"}\"")
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
### Usage in Code
|
|
499
|
+
|
|
500
|
+
```kotlin
|
|
501
|
+
// Access via BuildConfig -- obfuscated by R8 in release builds
|
|
502
|
+
val apiKey = BuildConfig.MAPS_API_KEY
|
|
503
|
+
val baseUrl = BuildConfig.BASE_URL
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
### Manifest Placeholder
|
|
507
|
+
|
|
508
|
+
```kotlin
|
|
509
|
+
android {
|
|
510
|
+
defaultConfig {
|
|
511
|
+
manifestPlaceholders["MAPS_API_KEY"] = localProperties["MAPS_API_KEY"] ?: ""
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
```xml
|
|
517
|
+
<meta-data
|
|
518
|
+
android:name="com.google.android.geo.API_KEY"
|
|
519
|
+
android:value="${MAPS_API_KEY}" />
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
## R8 Obfuscation
|
|
523
|
+
|
|
524
|
+
R8 generates `mapping.txt` for deobfuscating crash reports:
|
|
525
|
+
|
|
526
|
+
```kotlin
|
|
527
|
+
android {
|
|
528
|
+
buildTypes {
|
|
529
|
+
release {
|
|
530
|
+
isMinifyEnabled = true
|
|
531
|
+
isShrinkResources = true
|
|
532
|
+
proguardFiles(
|
|
533
|
+
getDefaultProguardFile("proguard-android-optimize.txt"),
|
|
534
|
+
"proguard-rules.pro",
|
|
535
|
+
)
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
Upload `mapping.txt` to Google Play Console and Firebase Crashlytics for
|
|
542
|
+
readable stack traces.
|
|
543
|
+
|
|
544
|
+
## Play Integrity API
|
|
545
|
+
|
|
546
|
+
Verify that API requests come from a genuine app on a genuine device.
|
|
547
|
+
|
|
548
|
+
```kotlin
|
|
549
|
+
class IntegrityVerifier @Inject constructor(
|
|
550
|
+
@ApplicationContext private val context: Context,
|
|
551
|
+
) {
|
|
552
|
+
suspend fun getIntegrityToken(nonce: String): String? {
|
|
553
|
+
return try {
|
|
554
|
+
val integrityManager = IntegrityManagerFactory.create(context)
|
|
555
|
+
|
|
556
|
+
val request = IntegrityTokenRequest.builder()
|
|
557
|
+
.setNonce(nonce)
|
|
558
|
+
.build()
|
|
559
|
+
|
|
560
|
+
val response = integrityManager
|
|
561
|
+
.requestIntegrityToken(request)
|
|
562
|
+
.await()
|
|
563
|
+
|
|
564
|
+
response.token()
|
|
565
|
+
} catch (e: Exception) {
|
|
566
|
+
null
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
Send the token to your server for verification. Never verify integrity tokens
|
|
573
|
+
on the client -- the server must decode and validate them via the Play Integrity
|
|
574
|
+
API.
|
|
575
|
+
|
|
576
|
+
## Root Detection
|
|
577
|
+
|
|
578
|
+
Root detection is defense-in-depth. Determined attackers can bypass it, but it
|
|
579
|
+
raises the bar.
|
|
580
|
+
|
|
581
|
+
```kotlin
|
|
582
|
+
object RootDetector {
|
|
583
|
+
|
|
584
|
+
fun isDeviceRooted(): Boolean =
|
|
585
|
+
checkRootBinaries() || checkSuExists() || checkRootApps()
|
|
586
|
+
|
|
587
|
+
private fun checkRootBinaries(): Boolean {
|
|
588
|
+
val paths = listOf(
|
|
589
|
+
"/system/bin/su",
|
|
590
|
+
"/system/xbin/su",
|
|
591
|
+
"/sbin/su",
|
|
592
|
+
"/system/app/Superuser.apk",
|
|
593
|
+
"/system/app/SuperSU.apk",
|
|
594
|
+
)
|
|
595
|
+
return paths.any { File(it).exists() }
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
private fun checkSuExists(): Boolean = try {
|
|
599
|
+
Runtime.getRuntime().exec("which su").inputStream.bufferedReader().readLine() != null
|
|
600
|
+
} catch (e: Exception) {
|
|
601
|
+
false
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
private fun checkRootApps(): Boolean {
|
|
605
|
+
val rootPackages = listOf(
|
|
606
|
+
"com.topjohnwu.magisk",
|
|
607
|
+
"eu.chainfire.supersu",
|
|
608
|
+
"com.koushikdutta.superuser",
|
|
609
|
+
)
|
|
610
|
+
// Check if any are installed (requires QUERY_ALL_PACKAGES or specific queries)
|
|
611
|
+
return false // Simplified -- use PackageManager in production
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
Use root detection as one signal among many. Combine with Play Integrity for
|
|
617
|
+
stronger guarantees.
|
|
618
|
+
|
|
619
|
+
## Secure Network Communication
|
|
620
|
+
|
|
621
|
+
### Enforce HTTPS
|
|
622
|
+
|
|
623
|
+
```xml
|
|
624
|
+
<!-- network_security_config.xml -->
|
|
625
|
+
<network-security-config>
|
|
626
|
+
<base-config cleartextTrafficPermitted="false" />
|
|
627
|
+
</network-security-config>
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
### Debug-Only Cleartext (for local development)
|
|
631
|
+
|
|
632
|
+
```xml
|
|
633
|
+
<network-security-config>
|
|
634
|
+
<base-config cleartextTrafficPermitted="false" />
|
|
635
|
+
<domain-config cleartextTrafficPermitted="true">
|
|
636
|
+
<domain includeSubdomains="false">10.0.2.2</domain>
|
|
637
|
+
<domain includeSubdomains="false">localhost</domain>
|
|
638
|
+
</domain-config>
|
|
639
|
+
</network-security-config>
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
### TLS Configuration
|
|
643
|
+
|
|
644
|
+
```kotlin
|
|
645
|
+
val spec = ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS)
|
|
646
|
+
.tlsVersions(TlsVersion.TLS_1_2, TlsVersion.TLS_1_3)
|
|
647
|
+
.build()
|
|
648
|
+
|
|
649
|
+
val client = OkHttpClient.Builder()
|
|
650
|
+
.connectionSpecs(listOf(spec))
|
|
651
|
+
.build()
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
## Secure Coding Patterns
|
|
655
|
+
|
|
656
|
+
### Never Log Sensitive Data
|
|
657
|
+
|
|
658
|
+
```kotlin
|
|
659
|
+
// WRONG
|
|
660
|
+
Log.d("Auth", "Token: $token")
|
|
661
|
+
Log.d("Payment", "Card: $cardNumber")
|
|
662
|
+
|
|
663
|
+
// CORRECT
|
|
664
|
+
Log.d("Auth", "Token refresh successful")
|
|
665
|
+
Log.d("Payment", "Payment processed")
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
### Input Validation
|
|
669
|
+
|
|
670
|
+
```kotlin
|
|
671
|
+
fun validateDeepLink(uri: Uri): Boolean {
|
|
672
|
+
val allowedHosts = setOf("example.com", "www.example.com")
|
|
673
|
+
val allowedSchemes = setOf("https")
|
|
674
|
+
|
|
675
|
+
return uri.scheme in allowedSchemes &&
|
|
676
|
+
uri.host in allowedHosts &&
|
|
677
|
+
!uri.path.orEmpty().contains("..")
|
|
678
|
+
}
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
### Secure WebView
|
|
682
|
+
|
|
683
|
+
```kotlin
|
|
684
|
+
webView.settings.apply {
|
|
685
|
+
javaScriptEnabled = false // Enable ONLY if required
|
|
686
|
+
allowFileAccess = false
|
|
687
|
+
allowContentAccess = false
|
|
688
|
+
domStorageEnabled = false
|
|
689
|
+
}
|
|
690
|
+
```
|
package/pipeline/skills/shared/external/{android_ui_verification → android-ui-verification}/SKILL.md
RENAMED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
name:
|
|
2
|
+
name: android-ui-verification
|
|
3
3
|
description: "Automated end-to-end UI testing and verification on an Android Emulator using ADB. Use when an Android build needs driving on an emulator to verify its UI end to end."
|
|
4
4
|
risk: safe
|
|
5
5
|
source: community
|