@necessarysdk/react-native 0.0.0-stage → 0.1.6
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 +40 -0
- package/LICENSE +201 -0
- package/NOTICE +2 -0
- package/NecessaryReactNative.podspec +23 -0
- package/README.md +429 -2
- package/android/build.gradle +83 -0
- package/android/src/main/AndroidManifest.xml +41 -0
- package/android/src/main/cpp/CMakeLists.txt +7 -0
- package/android/src/main/cpp/code_integrity.cpp +484 -0
- package/android/src/main/java/com/necessarysdk/reactnative/Causes.kt +49 -0
- package/android/src/main/java/com/necessarysdk/reactnative/InstallationKeyManager.kt +388 -0
- package/android/src/main/java/com/necessarysdk/reactnative/IntegrityProbeManager.kt +1157 -0
- package/android/src/main/java/com/necessarysdk/reactnative/KeyPresence.kt +62 -0
- package/android/src/main/java/com/necessarysdk/reactnative/NecessaryModules.kt +94 -0
- package/android/src/main/java/com/necessarysdk/reactnative/NecessaryNative.kt +338 -0
- package/android/src/main/java/com/necessarysdk/reactnative/NecessaryPackage.kt +38 -0
- package/android/src/main/java/com/necessarysdk/reactnative/ProbeReads.kt +333 -0
- package/android/src/main/java/com/necessarysdk/reactnative/ScreenGuard.kt +56 -0
- package/android/src/main/java/com/necessarysdk/reactnative/SecureStore.kt +127 -0
- package/android/src/main/java/com/necessarysdk/reactnative/StepUpKeyEntry.kt +57 -0
- package/android/src/main/java/com/necessarysdk/reactnative/StepUpKeyManager.kt +767 -0
- package/contract/report-1/android-8-ios-5/RULES.md +149 -0
- package/contract/report-1/android-8-ios-5/report.schema.json +2753 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-complete.json +205 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-incomplete.json +205 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-invalid.json +205 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-location-one-fix.json +228 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-location-two-fixes.json +228 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-refused.json +208 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-complete.json +106 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-incomplete.json +105 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-invalid.json +107 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-partial.json +110 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-refused.json +109 -0
- package/doc/integration_guide.md +771 -0
- package/doc/measurements.md +774 -0
- package/ios/InstallationKeyManager.swift +694 -0
- package/ios/IntegrityProbeManager.swift +959 -0
- package/ios/NecessaryModules.h +34 -0
- package/ios/NecessaryModules.mm +208 -0
- package/ios/NecessaryNative.swift +327 -0
- package/ios/PrivacyInfo.xcprivacy +14 -0
- package/ios/ProbeReads.swift +370 -0
- package/lib/client.d.ts +105 -0
- package/lib/client.js +176 -0
- package/lib/collector.d.ts +18 -0
- package/lib/collector.js +49 -0
- package/lib/deviceTrust.d.ts +253 -0
- package/lib/deviceTrust.js +699 -0
- package/lib/errors.d.ts +43 -0
- package/lib/errors.js +56 -0
- package/lib/index.d.ts +18 -0
- package/lib/index.js +34 -0
- package/lib/installationKey.d.ts +62 -0
- package/lib/installationKey.js +185 -0
- package/lib/installationStore.d.ts +49 -0
- package/lib/installationStore.js +127 -0
- package/lib/native/NativeNecessaryInstallationKey.d.ts +10 -0
- package/lib/native/NativeNecessaryInstallationKey.js +4 -0
- package/lib/native/NativeNecessaryIntegrity.d.ts +8 -0
- package/lib/native/NativeNecessaryIntegrity.js +4 -0
- package/lib/native/NativeNecessarySecureScreen.d.ts +7 -0
- package/lib/native/NativeNecessarySecureScreen.js +4 -0
- package/lib/native/NativeNecessaryStepUpKey.d.ts +9 -0
- package/lib/native/NativeNecessaryStepUpKey.js +4 -0
- package/lib/native/NativeNecessaryStorage.d.ts +14 -0
- package/lib/native/NativeNecessaryStorage.js +4 -0
- package/lib/protocol.d.ts +24 -0
- package/lib/protocol.js +50 -0
- package/lib/reinstallHint.d.ts +23 -0
- package/lib/reinstallHint.js +65 -0
- package/lib/report.d.ts +62 -0
- package/lib/report.js +134 -0
- package/lib/requestProof.d.ts +116 -0
- package/lib/requestProof.js +220 -0
- package/lib/secureScreen.d.ts +15 -0
- package/lib/secureScreen.js +32 -0
- package/lib/stepUpKey.d.ts +105 -0
- package/lib/stepUpKey.js +187 -0
- package/lib/transport.d.ts +39 -0
- package/lib/transport.js +117 -0
- package/lib/util/answers.d.ts +6 -0
- package/lib/util/answers.js +16 -0
- package/lib/util/bytes.d.ts +18 -0
- package/lib/util/bytes.js +207 -0
- package/lib/util/native.d.ts +20 -0
- package/lib/util/native.js +49 -0
- package/lib/util/release.d.ts +2 -0
- package/lib/util/release.js +7 -0
- package/lib/util/target.d.ts +18 -0
- package/lib/util/target.js +128 -0
- package/package.json +81 -4
- package/src/client.ts +221 -0
- package/src/collector.ts +64 -0
- package/src/deviceTrust.ts +851 -0
- package/src/errors.ts +71 -0
- package/src/index.ts +19 -0
- package/src/installationKey.ts +223 -0
- package/src/installationStore.ts +143 -0
- package/src/native/NativeNecessaryInstallationKey.ts +12 -0
- package/src/native/NativeNecessaryIntegrity.ts +15 -0
- package/src/native/NativeNecessarySecureScreen.ts +9 -0
- package/src/native/NativeNecessaryStepUpKey.ts +11 -0
- package/src/native/NativeNecessaryStorage.ts +16 -0
- package/src/protocol.ts +52 -0
- package/src/reinstallHint.ts +69 -0
- package/src/report.ts +168 -0
- package/src/requestProof.ts +295 -0
- package/src/secureScreen.ts +29 -0
- package/src/stepUpKey.ts +238 -0
- package/src/transport.ts +145 -0
- package/src/util/answers.ts +15 -0
- package/src/util/bytes.ts +194 -0
- package/src/util/native.ts +56 -0
- package/src/util/release.ts +6 -0
- package/src/util/target.ts +132 -0
- package/vectors/client_server.json +264 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
package com.necessarysdk.reactnative
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a probe makes of what it read: pure functions over the raw reads, with
|
|
5
|
+
* no Android API in them, so the JVM unit tests can hand them every way a
|
|
6
|
+
* read fails.
|
|
7
|
+
*
|
|
8
|
+
* The rule: a failed read is never a clean negative. A probe that could read
|
|
9
|
+
* nothing answers status "error"; one that read some parts answers "ok" with
|
|
10
|
+
* those parts and names the rest in "unread", each with why. The server takes
|
|
11
|
+
* either as missing evidence, never as a measured "no".
|
|
12
|
+
*/
|
|
13
|
+
object ProbeReads {
|
|
14
|
+
|
|
15
|
+
/** One command's run: started at all, finished in time, its exit code and output. */
|
|
16
|
+
data class CommandResult(
|
|
17
|
+
val started: Boolean,
|
|
18
|
+
val finished: Boolean,
|
|
19
|
+
val exitCode: Int?,
|
|
20
|
+
val output: String,
|
|
21
|
+
val failure: String? = null,
|
|
22
|
+
) {
|
|
23
|
+
/** Why the command gave no answer, or null when it ran to the end. */
|
|
24
|
+
val problem: String?
|
|
25
|
+
get() = when {
|
|
26
|
+
!started -> "could not start" + (failure?.let { ": $it" } ?: "")
|
|
27
|
+
!finished -> "did not finish in time"
|
|
28
|
+
else -> null
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** How one attempt to reach a local port ended. */
|
|
33
|
+
sealed class PortResult {
|
|
34
|
+
object Open : PortResult()
|
|
35
|
+
/** Refused: nothing listens there - a measured "closed". */
|
|
36
|
+
object Refused : PortResult()
|
|
37
|
+
/** Anything else: the test could not be made. */
|
|
38
|
+
data class Failed(val why: String) : PortResult()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
fun ok(vararg pairs: Pair<String, Any?>): MutableMap<String, Any> {
|
|
42
|
+
val result = linkedMapOf<String, Any>("status" to "ok")
|
|
43
|
+
for ((key, value) in pairs) if (value != null) result[key] = value
|
|
44
|
+
return result
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
fun error(reason: String): Map<String, Any> =
|
|
48
|
+
linkedMapOf("status" to "error", "error" to reason.take(240))
|
|
49
|
+
|
|
50
|
+
private fun withUnread(result: MutableMap<String, Any>, unread: Map<String, String>): Map<String, Any> {
|
|
51
|
+
if (unread.isNotEmpty()) {
|
|
52
|
+
result["unread"] = LinkedHashMap(unread.entries.take(64).associate { (part, why) ->
|
|
53
|
+
part.take(128) to why.take(160)
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
return result
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** /proc/mounts: lines, or null when the file could not be read. */
|
|
60
|
+
fun mounts(lines: List<String>?): Map<String, Any> {
|
|
61
|
+
if (lines == null) return error("/proc/mounts is not readable")
|
|
62
|
+
if (lines.isEmpty()) return error("/proc/mounts returned no lines")
|
|
63
|
+
val protectedPrefixes = listOf("/system", "/vendor", "/product", "/odm", "/system_ext")
|
|
64
|
+
val writable = mutableListOf<String>()
|
|
65
|
+
for (line in lines) {
|
|
66
|
+
val parts = line.split(' ')
|
|
67
|
+
if (parts.size < 4) continue
|
|
68
|
+
val mountPoint = parts[1]
|
|
69
|
+
val options = parts[3].split(',')
|
|
70
|
+
if (protectedPrefixes.any { mountPoint == it || mountPoint.startsWith("$it/") } &&
|
|
71
|
+
options.contains("rw")) {
|
|
72
|
+
writable.add("$mountPoint:${parts[3]}")
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return ok("protected_rw_mounts" to writable.distinct().take(32))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* /proc/self/status: lines, or null when unreadable. TracerPid is the
|
|
80
|
+
* measurement: missing or unparsable, nothing was measured - it is never
|
|
81
|
+
* taken as 0. Seccomp and NoNewPrivs are context, -1 when absent.
|
|
82
|
+
*/
|
|
83
|
+
fun tracer(lines: List<String>?): Map<String, Any> {
|
|
84
|
+
if (lines == null) return error("/proc/self/status is not readable")
|
|
85
|
+
var tracerPid: Int? = null
|
|
86
|
+
var tracerSeen = false
|
|
87
|
+
var seccomp = -1
|
|
88
|
+
var noNewPrivs = -1
|
|
89
|
+
for (line in lines) {
|
|
90
|
+
when {
|
|
91
|
+
line.startsWith("TracerPid:") -> {
|
|
92
|
+
tracerSeen = true
|
|
93
|
+
tracerPid = line.substringAfter(':').trim().toIntOrNull()
|
|
94
|
+
}
|
|
95
|
+
line.startsWith("Seccomp:") -> seccomp = line.substringAfter(':').trim().toIntOrNull() ?: -1
|
|
96
|
+
line.startsWith("NoNewPrivs:") -> noNewPrivs = line.substringAfter(':').trim().toIntOrNull() ?: -1
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (!tracerSeen) return error("TracerPid is missing from /proc/self/status")
|
|
100
|
+
val pid = tracerPid ?: return error("TracerPid is not a number")
|
|
101
|
+
return ok("tracer_pid" to pid, "seccomp" to seccomp, "no_new_privs" to noNewPrivs)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The listed names in one line of the memory map, in any case. The app's
|
|
106
|
+
* own folders and package name are taken out of the line first: Android
|
|
107
|
+
* names those folders after the package, with random text beside it, and
|
|
108
|
+
* neither names a tool. What lies inside those folders is still read, so a
|
|
109
|
+
* library the app was rebuilt with is found by its own name.
|
|
110
|
+
*/
|
|
111
|
+
fun namesIn(line: String, ownFolders: List<String>, packageName: String, tokens: List<String>): List<String> {
|
|
112
|
+
var text = line
|
|
113
|
+
for (folder in ownFolders) text = text.replace(folder, "/")
|
|
114
|
+
val lower = text.replace(packageName, "").lowercase(java.util.Locale.US)
|
|
115
|
+
return tokens.filter { lower.contains(it) }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The two developer settings, each read on its own: a read that threw is unread, not "off". */
|
|
119
|
+
fun developerSettings(adb: Result<Boolean>, developer: Result<Boolean>): Map<String, Any> {
|
|
120
|
+
if (adb.isFailure && developer.isFailure) {
|
|
121
|
+
return error("neither developer setting could be read: " + why(adb.exceptionOrNull()))
|
|
122
|
+
}
|
|
123
|
+
val unread = linkedMapOf<String, String>()
|
|
124
|
+
adb.exceptionOrNull()?.let { unread["adb_enabled"] = why(it) }
|
|
125
|
+
developer.exceptionOrNull()?.let { unread["developer_options_enabled"] = why(it) }
|
|
126
|
+
return withUnread(
|
|
127
|
+
ok("adb_enabled" to adb.getOrNull(), "developer_options_enabled" to developer.getOrNull()),
|
|
128
|
+
unread
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** `command -v su`: a lookup that could not run, or never finished, is not "no su". */
|
|
133
|
+
fun rootShell(lookup: CommandResult): Map<String, Any> {
|
|
134
|
+
lookup.problem?.let { return error("su lookup $it") }
|
|
135
|
+
val path = lookup.output.trim()
|
|
136
|
+
return ok("su_path" to path.take(512), "su_found" to path.isNotEmpty())
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** One getprop run per property: a property not read is unread, never "". */
|
|
140
|
+
fun systemProperties(results: Map<String, CommandResult>): Map<String, Any> {
|
|
141
|
+
val values = linkedMapOf<String, String>()
|
|
142
|
+
val unread = linkedMapOf<String, String>()
|
|
143
|
+
for ((name, result) in results) {
|
|
144
|
+
val problem = result.problem
|
|
145
|
+
if (problem != null) unread[name] = "getprop $problem" else values[name] = result.output.trim().take(256)
|
|
146
|
+
}
|
|
147
|
+
if (values.isEmpty()) return error("no property could be read: " + unread.values.firstOrNull().orEmpty())
|
|
148
|
+
return withUnread(ok("properties" to values), unread)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Whether each root path exists: a check that threw is unread, never "absent". */
|
|
152
|
+
fun rootFiles(checks: Map<String, Result<Boolean>>, buildTags: String): Map<String, Any> {
|
|
153
|
+
if (checks.isNotEmpty() && checks.values.all { it.isFailure }) {
|
|
154
|
+
return error("no root path could be checked: " + why(checks.values.first().exceptionOrNull()))
|
|
155
|
+
}
|
|
156
|
+
val found = checks.filter { it.value.getOrNull() == true }.keys.toList()
|
|
157
|
+
val unread = linkedMapOf<String, String>()
|
|
158
|
+
for ((path, result) in checks) result.exceptionOrNull()?.let { unread[path] = why(it) }
|
|
159
|
+
return withUnread(
|
|
160
|
+
ok(
|
|
161
|
+
"found_paths" to found,
|
|
162
|
+
"build_tags" to buildTags,
|
|
163
|
+
"test_keys" to buildTags.lowercase(java.util.Locale.US).contains("test-keys")
|
|
164
|
+
),
|
|
165
|
+
unread
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Local ports: refused is a measured "closed"; any other failure is unread. */
|
|
170
|
+
fun fridaPorts(results: Map<Int, PortResult>): Map<String, Any> {
|
|
171
|
+
if (results.isNotEmpty() && results.values.all { it is PortResult.Failed }) {
|
|
172
|
+
return error("no port could be tested: " + (results.values.first() as PortResult.Failed).why)
|
|
173
|
+
}
|
|
174
|
+
val open = results.filter { it.value is PortResult.Open }.keys.toList()
|
|
175
|
+
val unread = linkedMapOf<String, String>()
|
|
176
|
+
for ((port, result) in results) if (result is PortResult.Failed) unread[port.toString()] = result.why
|
|
177
|
+
return withUnread(ok("open_ports" to open), unread)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* This process's thread names. A thread that ended between listing and
|
|
182
|
+
* reading is gone, not unread; one whose name could not be read is
|
|
183
|
+
* unread, since a renamed instrumentation thread could be that one. A
|
|
184
|
+
* thread named for the app itself - Android gives the main thread part of
|
|
185
|
+
* the package's name, 15 characters at most - names no tool.
|
|
186
|
+
*/
|
|
187
|
+
fun threads(
|
|
188
|
+
names: List<String>,
|
|
189
|
+
unreadable: Int,
|
|
190
|
+
fridaThreads: List<String>,
|
|
191
|
+
glibThreads: List<String>,
|
|
192
|
+
tokens: List<String>,
|
|
193
|
+
packageName: String = "",
|
|
194
|
+
): Map<String, Any> {
|
|
195
|
+
val named = names.filter { it.isNotEmpty() }
|
|
196
|
+
if (named.isEmpty()) return error("no thread of this process could be enumerated")
|
|
197
|
+
val fridaHits = linkedSetOf<String>()
|
|
198
|
+
val glibHits = linkedSetOf<String>()
|
|
199
|
+
val tokenHits = linkedSetOf<String>()
|
|
200
|
+
for (name in named) {
|
|
201
|
+
val lower = name.lowercase(java.util.Locale.US)
|
|
202
|
+
for (t in fridaThreads) if (lower == t || lower.startsWith(t)) fridaHits.add(t)
|
|
203
|
+
for (t in glibThreads) if (lower == t) glibHits.add(t)
|
|
204
|
+
if (!packageName.contains(name)) {
|
|
205
|
+
for (t in tokens) if (lower.contains(t)) tokenHits.add(t)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
val unread = if (unreadable > 0) {
|
|
209
|
+
mapOf("thread_names" to "$unreadable of ${named.size + unreadable} could not be read")
|
|
210
|
+
} else {
|
|
211
|
+
emptyMap()
|
|
212
|
+
}
|
|
213
|
+
return withUnread(
|
|
214
|
+
ok(
|
|
215
|
+
"frida_threads" to fridaHits.toList(),
|
|
216
|
+
"glib_threads" to glibHits.toList(),
|
|
217
|
+
"token_threads" to tokenHits.toList(),
|
|
218
|
+
"thread_count" to named.size
|
|
219
|
+
),
|
|
220
|
+
unread
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Split APKs listed, at most: the server's bound for any list. */
|
|
225
|
+
const val MAX_SPLITS = 64
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The installed split APKs, apart from the base APK: apk_sha256 stays the base APK's alone, so pins
|
|
229
|
+
* and baselines keep their meaning. Each split by file name with its
|
|
230
|
+
* SHA-256, sorted by name; a split that could not be hashed is unread,
|
|
231
|
+
* never left out. No splits is an empty list, a measurement of its own.
|
|
232
|
+
*/
|
|
233
|
+
fun splitApks(hashes: Map<String, Result<String>>): Pair<List<Map<String, String>>, Map<String, String>> {
|
|
234
|
+
val listed = hashes.entries.filter { it.value.isSuccess }
|
|
235
|
+
.sortedBy { it.key }
|
|
236
|
+
.take(MAX_SPLITS)
|
|
237
|
+
.map { (name, hash) -> linkedMapOf("name" to name.take(128), "sha256" to hash.getOrThrow()) }
|
|
238
|
+
val unread = linkedMapOf<String, String>()
|
|
239
|
+
for ((name, hash) in hashes.entries.sortedBy { it.key }) {
|
|
240
|
+
hash.exceptionOrNull()?.let { unread["split_apks:" + name.take(116)] = why(it) }
|
|
241
|
+
}
|
|
242
|
+
return listed to unread
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** One last-known fix as the collection captured it, with no Android type in it. */
|
|
246
|
+
data class Fix(
|
|
247
|
+
val latitude: Double,
|
|
248
|
+
val longitude: Double,
|
|
249
|
+
val accuracyM: Double?,
|
|
250
|
+
val ageSeconds: Long,
|
|
251
|
+
val elapsedNanos: Long,
|
|
252
|
+
val provider: String,
|
|
253
|
+
val timeMillis: Long,
|
|
254
|
+
val mock: Boolean,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The one location read of a collection: the permission the app held, the
|
|
259
|
+
* newest fix the providers gave (null: none held one), the providers that
|
|
260
|
+
* could not be read, with why - or why nothing could be read at all, when
|
|
261
|
+
* the permission is not reported either.
|
|
262
|
+
*/
|
|
263
|
+
data class FixRead(
|
|
264
|
+
val permission: String?,
|
|
265
|
+
val fix: Fix? = null,
|
|
266
|
+
val unread: Map<String, String> = emptyMap(),
|
|
267
|
+
val failure: String? = null,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The location and mock-location measurements answer from one read, so the coordinates and the mock flag
|
|
272
|
+
* describe one fix. Read on first use and never again; a read that threw
|
|
273
|
+
* is a failed read, not retried. Each collection makes its own.
|
|
274
|
+
*/
|
|
275
|
+
class LocationCapture(read: () -> FixRead) {
|
|
276
|
+
val value: FixRead by lazy {
|
|
277
|
+
try {
|
|
278
|
+
read()
|
|
279
|
+
} catch (error: Throwable) {
|
|
280
|
+
FixRead(null, failure = "location read failed: " + why(error))
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The newest fix the providers gave, each read once: a provider that threw
|
|
287
|
+
* is unread, never "no fix"; when none could be read, the read failed.
|
|
288
|
+
*/
|
|
289
|
+
fun newestFix(permission: String, providers: Map<String, Result<Fix?>>): FixRead {
|
|
290
|
+
if (permission == "none") return FixRead(permission)
|
|
291
|
+
val unread = linkedMapOf<String, String>()
|
|
292
|
+
for ((provider, result) in providers) result.exceptionOrNull()?.let { unread[provider] = why(it) }
|
|
293
|
+
if (providers.isNotEmpty() && unread.size == providers.size) {
|
|
294
|
+
return FixRead(permission, failure = "no location provider could be read: " + unread.values.first())
|
|
295
|
+
}
|
|
296
|
+
val fix = providers.values.mapNotNull { it.getOrNull() }.maxByOrNull { it.elapsedNanos }
|
|
297
|
+
return FixRead(permission, fix, unread)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** The last known location: the coordinates of the captured fix. */
|
|
301
|
+
fun location(read: FixRead): Map<String, Any> {
|
|
302
|
+
read.failure?.let { return error(it) }
|
|
303
|
+
val result = ok("permission" to read.permission)
|
|
304
|
+
read.fix?.let { fix ->
|
|
305
|
+
result["latitude"] = fix.latitude
|
|
306
|
+
result["longitude"] = fix.longitude
|
|
307
|
+
fix.accuracyM?.let { result["accuracy_m"] = it }
|
|
308
|
+
result["age_seconds"] = fix.ageSeconds
|
|
309
|
+
result["provider"] = fix.provider.take(32)
|
|
310
|
+
result["fix_time"] = fix.timeMillis / 1000
|
|
311
|
+
}
|
|
312
|
+
return withUnread(result, read.unread)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Whether the captured fix came from a mock-location app: never its coordinates. */
|
|
316
|
+
fun mockLocation(read: FixRead): Map<String, Any> {
|
|
317
|
+
read.failure?.let { return error(it) }
|
|
318
|
+
val result = ok("permission" to read.permission)
|
|
319
|
+
read.fix?.let { fix ->
|
|
320
|
+
result["mock"] = fix.mock
|
|
321
|
+
result["age_seconds"] = fix.ageSeconds
|
|
322
|
+
result["fix_time"] = fix.timeMillis / 1000
|
|
323
|
+
}
|
|
324
|
+
return withUnread(result, read.unread)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Why a read failed, for `unread` and a probe's `error`: the failure and
|
|
329
|
+
* every cause beneath it (Causes), within the contract's 160 characters.
|
|
330
|
+
*/
|
|
331
|
+
internal fun why(error: Throwable?): String =
|
|
332
|
+
Causes.chain(error).ifEmpty { "unknown" }.take(160)
|
|
333
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
package com.necessarysdk.reactnative
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Screen protection that respects its host. The first caller records what the
|
|
5
|
+
* window has -- FLAG_SECURE, and whether it drops touches that passed through
|
|
6
|
+
* another app's window -- and protects it; later callers only count; the last
|
|
7
|
+
* one to finish puts back exactly what was recorded, so a host that keeps its
|
|
8
|
+
* window secure itself stays secure. Other apps' overlays, whose state no
|
|
9
|
+
* public call reads, are hidden while protected and shown again after. Kept
|
|
10
|
+
* apart from the window so that it runs on the JVM.
|
|
11
|
+
*/
|
|
12
|
+
internal class ScreenGuard {
|
|
13
|
+
/** What a window has, of what screen protection changes and can read. */
|
|
14
|
+
data class WindowState(val secure: Boolean, val dropsObscuredTouches: Boolean)
|
|
15
|
+
|
|
16
|
+
/** What to make of the window. */
|
|
17
|
+
data class Change(
|
|
18
|
+
val secure: Boolean,
|
|
19
|
+
val dropsObscuredTouches: Boolean,
|
|
20
|
+
val hideOverlays: Boolean,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
private var holders = 0
|
|
24
|
+
private var before: WindowState? = null
|
|
25
|
+
|
|
26
|
+
val held: Boolean get() = holders > 0
|
|
27
|
+
|
|
28
|
+
/** A caller protects the window; only the first changes it. */
|
|
29
|
+
fun acquire(current: WindowState): Change? {
|
|
30
|
+
holders += 1
|
|
31
|
+
if (holders > 1) return null
|
|
32
|
+
before = current
|
|
33
|
+
return PROTECTED
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A caller is done; the last puts the window back. An extra release does nothing. */
|
|
37
|
+
fun release(): Change? {
|
|
38
|
+
if (holders == 0) return null
|
|
39
|
+
holders -= 1
|
|
40
|
+
if (holders > 0) return null
|
|
41
|
+
val saved = before ?: return null
|
|
42
|
+
before = null
|
|
43
|
+
return Change(saved.secure, saved.dropsObscuredTouches, hideOverlays = false)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A new window for the screen -- the activity was recreated -- while protected. */
|
|
47
|
+
fun reattach(current: WindowState): Change? {
|
|
48
|
+
if (holders == 0) return null
|
|
49
|
+
before = current
|
|
50
|
+
return PROTECTED
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
companion object {
|
|
54
|
+
val PROTECTED = Change(secure = true, dropsObscuredTouches = true, hideOverlays = true)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
package com.necessarysdk.reactnative
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.security.keystore.KeyGenParameterSpec
|
|
5
|
+
import android.security.keystore.KeyProperties
|
|
6
|
+
import android.util.Base64
|
|
7
|
+
import java.security.GeneralSecurityException
|
|
8
|
+
import java.security.KeyStore
|
|
9
|
+
import javax.crypto.Cipher
|
|
10
|
+
import javax.crypto.KeyGenerator
|
|
11
|
+
import javax.crypto.SecretKey
|
|
12
|
+
import javax.crypto.spec.GCMParameterSpec
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The installation ID and the pending step-up re-enrolment, kept in the app's private
|
|
16
|
+
* preferences, each value encrypted (AES-256-GCM) under a key AndroidKeyStore holds and never
|
|
17
|
+
* exports. The key is made only where the keystore, answering, holds none (KeyPresence): made over
|
|
18
|
+
* one the keystore could not reach, it would leave every stored value unreadable. A value that
|
|
19
|
+
* cannot be decrypted is reported, never read as absent.
|
|
20
|
+
*/
|
|
21
|
+
internal class SecureStore(context: Context) {
|
|
22
|
+
companion object {
|
|
23
|
+
private const val ANDROID_KEY_STORE = "AndroidKeyStore"
|
|
24
|
+
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
|
25
|
+
private const val TAG_BITS = 128
|
|
26
|
+
private const val PREFIX = "v1:"
|
|
27
|
+
|
|
28
|
+
// One reader or writer at a time in the process, whichever runtime asks.
|
|
29
|
+
private val lock = Any()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private val preferences =
|
|
33
|
+
context.applicationContext.getSharedPreferences("com.necessarysdk.reactnative.storage", Context.MODE_PRIVATE)
|
|
34
|
+
private val keyAlias = "${context.packageName}.necessary.storage.v1"
|
|
35
|
+
private val probeAlias = "$keyAlias.probe"
|
|
36
|
+
|
|
37
|
+
fun read(name: String): String? = synchronized(lock) {
|
|
38
|
+
val stored = preferences.getString(name, null) ?: return null
|
|
39
|
+
try {
|
|
40
|
+
if (!stored.startsWith(PREFIX)) throw IllegalArgumentException("unknown format")
|
|
41
|
+
val bytes = Base64.decode(stored.substring(PREFIX.length), Base64.NO_WRAP)
|
|
42
|
+
val cipher = Cipher.getInstance(TRANSFORMATION)
|
|
43
|
+
cipher.init(Cipher.DECRYPT_MODE, existingKey(), GCMParameterSpec(TAG_BITS, bytes, 0, 12))
|
|
44
|
+
String(cipher.doFinal(bytes, 12, bytes.size - 12), Charsets.UTF_8)
|
|
45
|
+
} catch (error: InstallationKeyFailure) {
|
|
46
|
+
throw error
|
|
47
|
+
} catch (error: Exception) {
|
|
48
|
+
throw InstallationKeyFailure(
|
|
49
|
+
"STORAGE_UNREADABLE",
|
|
50
|
+
"The stored value '$name' could not be decrypted; it was kept.",
|
|
51
|
+
error,
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
fun write(name: String, value: String): Boolean = synchronized(lock) {
|
|
57
|
+
val cipher = try {
|
|
58
|
+
Cipher.getInstance(TRANSFORMATION).apply { init(Cipher.ENCRYPT_MODE, keyForWriting()) }
|
|
59
|
+
} catch (error: GeneralSecurityException) {
|
|
60
|
+
throw InstallationKeyFailure("STORAGE_WRITE_FAILED", "The value '$name' could not be encrypted.", error)
|
|
61
|
+
}
|
|
62
|
+
val iv = cipher.iv
|
|
63
|
+
val sealed = cipher.doFinal(value.toByteArray(Charsets.UTF_8))
|
|
64
|
+
val encoded = PREFIX + Base64.encodeToString(iv + sealed, Base64.NO_WRAP)
|
|
65
|
+
if (!preferences.edit().putString(name, encoded).commit()) {
|
|
66
|
+
throw InstallationKeyFailure("STORAGE_WRITE_FAILED", "The value '$name' could not be written.")
|
|
67
|
+
}
|
|
68
|
+
true
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
fun remove(name: String): Boolean = synchronized(lock) {
|
|
72
|
+
if (!preferences.edit().remove(name).commit()) {
|
|
73
|
+
throw InstallationKeyFailure("STORAGE_WRITE_FAILED", "The value '$name' could not be removed.")
|
|
74
|
+
}
|
|
75
|
+
true
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private fun keyStore(): KeyStore =
|
|
79
|
+
try {
|
|
80
|
+
KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) }
|
|
81
|
+
} catch (error: Exception) {
|
|
82
|
+
throw InstallationKeyFailure("STORAGE_UNAVAILABLE", "AndroidKeyStore could not be opened.", error)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private fun existingKey(): SecretKey {
|
|
86
|
+
val keyStore = keyStore()
|
|
87
|
+
return keyStore.getKey(keyAlias, null) as? SecretKey
|
|
88
|
+
?: throw InstallationKeyFailure(
|
|
89
|
+
"STORAGE_UNREADABLE",
|
|
90
|
+
"The key the stored values are encrypted under is not in AndroidKeyStore.",
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private fun keyForWriting(): SecretKey {
|
|
95
|
+
val keyStore = keyStore()
|
|
96
|
+
val found = KeyPresence.of(object : KeyPresence.Keystore {
|
|
97
|
+
override fun containsKey() = keyStore.containsAlias(keyAlias)
|
|
98
|
+
override fun deleteProbe() = keyStore.deleteEntry(probeAlias)
|
|
99
|
+
})
|
|
100
|
+
when (found.answer) {
|
|
101
|
+
KeyPresence.Answer.PRESENT -> return keyStore.getKey(keyAlias, null) as? SecretKey
|
|
102
|
+
?: throw InstallationKeyFailure(
|
|
103
|
+
"STORAGE_UNAVAILABLE",
|
|
104
|
+
"AndroidKeyStore holds the storage key's alias but gave no key.",
|
|
105
|
+
)
|
|
106
|
+
KeyPresence.Answer.UNAVAILABLE -> throw InstallationKeyFailure(
|
|
107
|
+
"STORAGE_UNAVAILABLE",
|
|
108
|
+
"AndroidKeyStore could not be reached for the storage key; nothing was written.",
|
|
109
|
+
found.cause,
|
|
110
|
+
)
|
|
111
|
+
KeyPresence.Answer.ABSENT -> {
|
|
112
|
+
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE)
|
|
113
|
+
generator.init(
|
|
114
|
+
KeyGenParameterSpec.Builder(
|
|
115
|
+
keyAlias,
|
|
116
|
+
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
|
117
|
+
)
|
|
118
|
+
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
|
119
|
+
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
|
120
|
+
.setKeySize(256)
|
|
121
|
+
.build(),
|
|
122
|
+
)
|
|
123
|
+
return generator.generateKey()
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
package com.necessarysdk.reactnative
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What the step-up key's lookups found, and what that means for the key: a
|
|
5
|
+
* pure decision, so a JVM test holds it (the release review of 2026-10-31,
|
|
6
|
+
* item 4).
|
|
7
|
+
*
|
|
8
|
+
* Only a permanent invalidation that Android itself reports
|
|
9
|
+
* (KeyPermanentlyInvalidatedException when a signature starts) deletes the
|
|
10
|
+
* key. Every other failure is ambiguous -- on Android 9, KeyStore.contains
|
|
11
|
+
* answers false on a binder error, and an unsuccessful key-characteristics
|
|
12
|
+
* query surfaces as UnrecoverableKeyException -- so the key is kept and the
|
|
13
|
+
* app hears STEPUP_KEY_UNAVAILABLE, which it may retry; replacing the key is
|
|
14
|
+
* an explicit re-enrolment. Nothing here counts failures: two alike are no
|
|
15
|
+
* proof of permanence either.
|
|
16
|
+
*/
|
|
17
|
+
internal object StepUpKeyEntry {
|
|
18
|
+
/** What looking the key up found. */
|
|
19
|
+
enum class Lookup {
|
|
20
|
+
/** A private-key entry, loaded. */
|
|
21
|
+
PRIVATE_KEY,
|
|
22
|
+
/** isKeyEntry false: no key, or a keystore that could not say. */
|
|
23
|
+
NOT_KEY_ENTRY,
|
|
24
|
+
/** getEntry threw UnrecoverableKeyException. */
|
|
25
|
+
UNRECOVERABLE,
|
|
26
|
+
/** getEntry threw UnsupportedOperationException (the Android 9 orphaned alias). */
|
|
27
|
+
UNSUPPORTED,
|
|
28
|
+
/** getEntry returned an entry that is not a private key's. */
|
|
29
|
+
NOT_PRIVATE_KEY,
|
|
30
|
+
/** getEntry threw another GeneralSecurityException. */
|
|
31
|
+
OTHER_FAILURE,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What starting a signature with the loaded key found. */
|
|
35
|
+
enum class SignStart { STARTED, NEEDS_AUTHENTICATION, PERMANENTLY_INVALIDATED, NOT_TRIED }
|
|
36
|
+
|
|
37
|
+
enum class Verdict {
|
|
38
|
+
/** Sign with it; a windowed key outside its window only needs the person. */
|
|
39
|
+
USABLE,
|
|
40
|
+
/** Android says it can never sign again: delete it, re-enrol. */
|
|
41
|
+
INVALIDATED,
|
|
42
|
+
/** Could not establish whether it is usable: keep it, STEPUP_KEY_UNAVAILABLE. */
|
|
43
|
+
UNAVAILABLE,
|
|
44
|
+
/** Another keystore failure: keep it, KEY_LOOKUP_FAILED. */
|
|
45
|
+
LOOKUP_FAILED,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
fun verdict(lookup: Lookup, start: SignStart): Verdict = when (lookup) {
|
|
49
|
+
Lookup.PRIVATE_KEY -> when (start) {
|
|
50
|
+
SignStart.PERMANENTLY_INVALIDATED -> Verdict.INVALIDATED
|
|
51
|
+
else -> Verdict.USABLE
|
|
52
|
+
}
|
|
53
|
+
Lookup.OTHER_FAILURE -> Verdict.LOOKUP_FAILED
|
|
54
|
+
Lookup.NOT_KEY_ENTRY, Lookup.UNRECOVERABLE, Lookup.UNSUPPORTED,
|
|
55
|
+
Lookup.NOT_PRIVATE_KEY -> Verdict.UNAVAILABLE
|
|
56
|
+
}
|
|
57
|
+
}
|