@oxy.so/services 10.0.0 → 11.0.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/android/build.gradle +1 -1
- package/android/src/main/AndroidManifest.xml +6 -6
- package/android/src/main/java/so/oxy/devicesession/OxyDeviceSessionModule.kt +106 -41
- package/android/src/main/java/so/oxy/devicesession/OxyDeviceSessionProvider.kt +45 -32
- package/android/src/main/java/so/oxy/devicesession/OxyDeviceSessionStore.kt +22 -27
- package/android/src/main/java/so/oxy/identity/OxyIdentityModule.kt +89 -43
- package/android/src/main/java/so/oxy/security/OxyCallerPolicy.kt +97 -0
- package/android/src/main/java/so/oxy/session/OxyBackgroundSessionStore.kt +4 -10
- package/android/src/main/java/so/oxy/storage/OxyEncryptedPrefs.kt +17 -18
- package/ios/OxyIdentityModule.swift +13 -14
- package/lib/commonjs/ui/boot/runProviderColdBoot.js +2 -2
- package/lib/commonjs/ui/session/tokenLossRecovery.js +1 -1
- package/lib/module/ui/boot/runProviderColdBoot.js +2 -2
- package/lib/module/ui/session/tokenLossRecovery.js +1 -1
- package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts +2 -2
- package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts +2 -2
- package/package.json +6 -6
- package/plugins/withOxySharedPermissions.js +89 -0
- package/plugins/withSharedDeviceSessionProvider.js +26 -71
- package/src/ui/boot/runProviderColdBoot.ts +2 -2
- package/src/ui/session/__tests__/tokenLossRecovery.test.ts +2 -2
- package/src/ui/session/tokenLossRecovery.ts +1 -1
- package/android/src/main/java/so/oxy/identity/OxyIdentityProvider.kt +0 -77
- package/android/src/main/java/so/oxy/identity/OxyIdentityStore.kt +0 -66
- package/plugins/withSharedDeviceSessionReader.js +0 -57
- package/plugins/withSharedIdentityProvider.js +0 -83
- package/plugins/withSharedIdentityReader.js +0 -46
package/android/build.gradle
CHANGED
|
@@ -16,6 +16,6 @@ android {
|
|
|
16
16
|
|
|
17
17
|
dependencies {
|
|
18
18
|
// Hardware-backed EncryptedSharedPreferences (MasterKey AES256_GCM) for the
|
|
19
|
-
//
|
|
19
|
+
// device-session and background-session credentials.
|
|
20
20
|
implementation "androidx.security:security-crypto:1.1.0-alpha06"
|
|
21
21
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
<manifest>
|
|
2
2
|
<!--
|
|
3
|
-
Intentionally empty. The
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
Intentionally empty. The config plugins inject everything per app:
|
|
4
|
+
`withOxySharedPermissions` declares and requests the signature-level
|
|
5
|
+
`so.oxy.permission.IDENTITY` and `so.oxy.permission.DEVICE_SESSION` in EVERY
|
|
6
|
+
Oxy app and adds the `<queries>` for the provider authorities;
|
|
7
|
+
`withSharedDeviceSessionProvider` hosts the device-session provider in the
|
|
8
|
+
host apps (Commons, Accounts). The identity provider is Commons' own module.
|
|
9
9
|
-->
|
|
10
10
|
</manifest>
|
|
@@ -10,11 +10,23 @@ import expo.modules.kotlin.modules.ModuleDefinition
|
|
|
10
10
|
/**
|
|
11
11
|
* JS bridge for the shared DeviceSession credential.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* The credential lives in the HOST apps only — Commons and Accounts, prod and
|
|
14
|
+
* dev ([HOST_AUTHORITIES]) — each in its own [OxyDeviceSessionStore]. Oxy apps
|
|
15
|
+
* do not share a UID, so no app can see another's files; everything else goes
|
|
16
|
+
* through the hosts' [OxyDeviceSessionProvider]:
|
|
17
|
+
*
|
|
18
|
+
* - `read` sweeps the hosts in [HOST_AUTHORITIES] order, Commons first, and
|
|
19
|
+
* returns the first credential it finds. A host reads ITSELF from its own
|
|
20
|
+
* store instead of calling its own provider.
|
|
21
|
+
* - `write` publishes to EVERY installed host (itself locally, the others
|
|
22
|
+
* through their providers). It reports success only when every installed
|
|
23
|
+
* host either confirmed the new credential by read-back or, having refused
|
|
24
|
+
* it, was cleared: a host left holding an OLDER credential would win the
|
|
25
|
+
* sweep and hand every app a stale secret.
|
|
26
|
+
* - `clear` drops the credential from every reachable host.
|
|
27
|
+
*
|
|
28
|
+
* An app that is not a host keeps no copy of its own: with no host installed
|
|
29
|
+
* there is no shared credential, which the sweep reports as `absent`.
|
|
18
30
|
*
|
|
19
31
|
* ## `read` returns a STATUS, not a nullable value
|
|
20
32
|
*
|
|
@@ -54,22 +66,22 @@ class OxyDeviceSessionModule : Module() {
|
|
|
54
66
|
if (deviceId.isEmpty() || deviceSecret.isEmpty()) {
|
|
55
67
|
return@AsyncFunction false
|
|
56
68
|
}
|
|
57
|
-
|
|
69
|
+
publish(deviceId, deviceSecret)
|
|
58
70
|
}
|
|
59
71
|
|
|
60
72
|
AsyncFunction("clear") {
|
|
61
|
-
|
|
73
|
+
clearShared()
|
|
62
74
|
}
|
|
63
75
|
}
|
|
64
76
|
|
|
77
|
+
/** This app's own provider authority; a host when it is in [HOST_AUTHORITIES]. */
|
|
78
|
+
private val selfAuthority: String
|
|
79
|
+
get() = "${context.packageName}$AUTHORITY_SUFFIX"
|
|
80
|
+
|
|
65
81
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* directory, so for a UID member the local file IS the group's file and the
|
|
70
|
-
* order changes nothing. It matters for a same-signature app OUTSIDE the UID:
|
|
71
|
-
* there the local file is a private mirror of whatever that app itself last
|
|
72
|
-
* wrote, so reading it first would shadow the group's real credential forever.
|
|
82
|
+
* The hosts in [HOST_AUTHORITIES] order; this app, if it is one, from its own
|
|
83
|
+
* store. Every app sweeps the same list in the same order, so they all adopt
|
|
84
|
+
* the same credential.
|
|
73
85
|
*
|
|
74
86
|
* `unavailable` is sticky across the whole sweep: if ANY source could not be
|
|
75
87
|
* read and none produced a credential, the answer is `unavailable`, not
|
|
@@ -79,8 +91,9 @@ class OxyDeviceSessionModule : Module() {
|
|
|
79
91
|
private fun readShared(): Map<String, String> {
|
|
80
92
|
var unavailableReason: String? = null
|
|
81
93
|
|
|
82
|
-
for (authority in
|
|
83
|
-
|
|
94
|
+
for (authority in HOST_AUTHORITIES) {
|
|
95
|
+
val read = if (authority == selfAuthority) OxyDeviceSessionStore.read(context) else callProvider(authority)
|
|
96
|
+
when (read) {
|
|
84
97
|
is DeviceSessionRead.Present -> return present(read)
|
|
85
98
|
is DeviceSessionRead.Unavailable -> unavailableReason = unavailableReason ?: read.reason
|
|
86
99
|
// Absent, or no provider there at all (not installed, refused, threw) —
|
|
@@ -89,12 +102,6 @@ class OxyDeviceSessionModule : Module() {
|
|
|
89
102
|
}
|
|
90
103
|
}
|
|
91
104
|
|
|
92
|
-
when (val local = OxyDeviceSessionStore.read(context)) {
|
|
93
|
-
is DeviceSessionRead.Present -> return present(local)
|
|
94
|
-
is DeviceSessionRead.Unavailable -> unavailableReason = unavailableReason ?: local.reason
|
|
95
|
-
is DeviceSessionRead.Absent -> Unit
|
|
96
|
-
}
|
|
97
|
-
|
|
98
105
|
val reason = unavailableReason
|
|
99
106
|
return if (reason != null) {
|
|
100
107
|
mapOf(
|
|
@@ -106,6 +113,70 @@ class OxyDeviceSessionModule : Module() {
|
|
|
106
113
|
}
|
|
107
114
|
}
|
|
108
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Write the credential to every installed host.
|
|
118
|
+
*
|
|
119
|
+
* The sweep adopts the FIRST host holding a credential, so a host that kept
|
|
120
|
+
* an older one would shadow this write for every app: they would adopt the
|
|
121
|
+
* stale secret, get a 401, sign in again, publish again, and loop. So a host
|
|
122
|
+
* that did not confirm the write is cleared, and the write counts only when
|
|
123
|
+
* at least one host confirmed it and no installed host is left holding
|
|
124
|
+
* anything else. Hosts that are not installed do not count.
|
|
125
|
+
*/
|
|
126
|
+
private fun publish(deviceId: String, deviceSecret: String): Boolean {
|
|
127
|
+
var confirmed = 0
|
|
128
|
+
var stale = false
|
|
129
|
+
for (authority in HOST_AUTHORITIES) {
|
|
130
|
+
if (!isInstalled(authority)) continue
|
|
131
|
+
val ok = if (authority == selfAuthority) {
|
|
132
|
+
OxyDeviceSessionStore.write(context, deviceId, deviceSecret)
|
|
133
|
+
} else {
|
|
134
|
+
callWrite(authority, deviceId, deviceSecret)
|
|
135
|
+
}
|
|
136
|
+
if (ok) {
|
|
137
|
+
confirmed += 1
|
|
138
|
+
} else if (!clearHost(authority)) {
|
|
139
|
+
stale = true
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return confirmed > 0 && !stale
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Drop the credential from every installed host. Best-effort. */
|
|
146
|
+
private fun clearShared() {
|
|
147
|
+
for (authority in HOST_AUTHORITIES) {
|
|
148
|
+
if (isInstalled(authority)) clearHost(authority)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Whether some app on this device hosts [authority] (and we may see it). */
|
|
153
|
+
private fun isInstalled(authority: String): Boolean =
|
|
154
|
+
authority == selfAuthority ||
|
|
155
|
+
runCatching { context.packageManager.resolveContentProvider(authority, 0) != null }.getOrDefault(false)
|
|
156
|
+
|
|
157
|
+
/** One host's `write`; false when it refused, threw, or did not confirm. */
|
|
158
|
+
private fun callWrite(authority: String, deviceId: String, deviceSecret: String): Boolean = runCatching {
|
|
159
|
+
val extras = Bundle().apply {
|
|
160
|
+
putString(OxyDeviceSessionStore.KEY_DEVICE_ID, deviceId)
|
|
161
|
+
putString(OxyDeviceSessionStore.KEY_DEVICE_SECRET, deviceSecret)
|
|
162
|
+
}
|
|
163
|
+
context.contentResolver
|
|
164
|
+
.call(Uri.parse("content://$authority"), OxyDeviceSessionProvider.METHOD_WRITE, null, extras)
|
|
165
|
+
?.getBoolean(OxyDeviceSessionProvider.KEY_OK, false) == true
|
|
166
|
+
}.getOrDefault(false)
|
|
167
|
+
|
|
168
|
+
/** Empty one host; true when it confirmed the store is empty. */
|
|
169
|
+
private fun clearHost(authority: String): Boolean =
|
|
170
|
+
if (authority == selfAuthority) {
|
|
171
|
+
OxyDeviceSessionStore.clear(context)
|
|
172
|
+
} else {
|
|
173
|
+
runCatching {
|
|
174
|
+
context.contentResolver
|
|
175
|
+
.call(Uri.parse("content://$authority"), OxyDeviceSessionProvider.METHOD_CLEAR, null, null)
|
|
176
|
+
?.getBoolean(OxyDeviceSessionProvider.KEY_OK, false) == true
|
|
177
|
+
}.getOrDefault(false)
|
|
178
|
+
}
|
|
179
|
+
|
|
109
180
|
private fun present(read: DeviceSessionRead.Present): Map<String, String> = mapOf(
|
|
110
181
|
OxyDeviceSessionProvider.KEY_STATUS to OxyDeviceSessionProvider.STATUS_PRESENT,
|
|
111
182
|
OxyDeviceSessionStore.KEY_DEVICE_ID to read.deviceId,
|
|
@@ -113,7 +184,7 @@ class OxyDeviceSessionModule : Module() {
|
|
|
113
184
|
)
|
|
114
185
|
|
|
115
186
|
/**
|
|
116
|
-
* Call one
|
|
187
|
+
* Call one host's `read`. `null` means "nothing to learn from this one" —
|
|
117
188
|
* the app is not installed, package visibility hid it, the permission was
|
|
118
189
|
* refused, or the call threw. A provider that answered but could not read its
|
|
119
190
|
* own store returns [DeviceSessionRead.Unavailable], which the sweep keeps.
|
|
@@ -144,35 +215,29 @@ class OxyDeviceSessionModule : Module() {
|
|
|
144
215
|
DeviceSessionRead.Unavailable(
|
|
145
216
|
bundle.getString(OxyDeviceSessionProvider.KEY_REASON) ?: "PeerUnavailable",
|
|
146
217
|
)
|
|
147
|
-
//
|
|
218
|
+
// A status this build does not know. It said something we do not
|
|
148
219
|
// understand, so we have learned nothing — never read that as "absent".
|
|
149
220
|
else -> DeviceSessionRead.Unavailable("UnrecognisedProviderStatus")
|
|
150
221
|
}
|
|
151
222
|
}.getOrNull()
|
|
152
223
|
|
|
153
224
|
companion object {
|
|
225
|
+
private const val AUTHORITY_SUFFIX = ".devicesession"
|
|
226
|
+
|
|
154
227
|
/**
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
* `
|
|
158
|
-
* variants are listed so a developer build can join a device too.
|
|
228
|
+
* The hosts, in sweep order: Commons, then Accounts, each prod before dev.
|
|
229
|
+
* Each hosts [OxyDeviceSessionProvider] at `${applicationId}.devicesession`
|
|
230
|
+
* through the `withSharedDeviceSessionProvider` config plugin.
|
|
159
231
|
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
* app that is same-signature but NOT in the UID would put a second,
|
|
164
|
-
* independent copy of "the shared credential" into the sweep and make the
|
|
165
|
-
* winner depend on list order.
|
|
166
|
-
*
|
|
167
|
-
* The same authorities must also be added to the `<queries>` block in
|
|
168
|
-
* `withSharedDeviceSession.js`, or Android 11+ package-visibility filtering
|
|
169
|
-
* hides them from `ContentResolver.call` and the sweep silently finds nothing.
|
|
232
|
+
* The same authorities must be in the `<queries>` of
|
|
233
|
+
* `withOxySharedPermissions.js`, or Android 11+ package visibility hides
|
|
234
|
+
* them from `ContentResolver.call` and the sweep silently finds nothing.
|
|
170
235
|
*/
|
|
171
|
-
private val
|
|
172
|
-
"so.oxy.accounts.devicesession",
|
|
173
|
-
"so.oxy.accounts.dev.devicesession",
|
|
236
|
+
private val HOST_AUTHORITIES = listOf(
|
|
174
237
|
"so.oxy.commons.devicesession",
|
|
175
238
|
"so.oxy.commons.dev.devicesession",
|
|
239
|
+
"so.oxy.accounts.devicesession",
|
|
240
|
+
"so.oxy.accounts.dev.devicesession",
|
|
176
241
|
)
|
|
177
242
|
}
|
|
178
243
|
}
|
|
@@ -2,29 +2,37 @@ package so.oxy.devicesession
|
|
|
2
2
|
|
|
3
3
|
import android.content.ContentProvider
|
|
4
4
|
import android.content.ContentValues
|
|
5
|
-
import android.content.Context
|
|
6
|
-
import android.content.pm.PackageManager
|
|
7
5
|
import android.database.Cursor
|
|
8
6
|
import android.net.Uri
|
|
9
7
|
import android.os.Bundle
|
|
8
|
+
import so.oxy.security.OxyCallerPolicy
|
|
10
9
|
|
|
11
10
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* The device's shared DeviceSession credential, hosted by the HOST apps
|
|
12
|
+
* (Commons and Accounts, prod and dev) at `${applicationId}.devicesession`,
|
|
13
|
+
* declared by the `withSharedDeviceSessionProvider` config plugin behind the
|
|
14
|
+
* `signature`-level `so.oxy.permission.DEVICE_SESSION`.
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
16
|
+
* Oxy apps do not share a UID, so each app's files are its own: the credential
|
|
17
|
+
* lives ONCE per host, in the host's own [OxyDeviceSessionStore], and every
|
|
18
|
+
* other app reads and writes it through this provider
|
|
19
|
+
* ([OxyDeviceSessionModule] sweeps and fans out across the hosts).
|
|
20
20
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* | method | extras | answer |
|
|
22
|
+
* |---------|----------------------------|------------------------------------------|
|
|
23
|
+
* | `read` | – | `status` (+ `deviceId`, `deviceSecret`) |
|
|
24
|
+
* | `write` | `deviceId`, `deviceSecret` | `ok`: whether a read-back confirmed it |
|
|
25
|
+
* | `clear` | – | `ok`: whether a read-back found it empty |
|
|
24
26
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
27
|
+
* The manifest permission is necessary but not sufficient: inside `call()`,
|
|
28
|
+
* [OxyCallerPolicy] takes the caller from the Binder (never from the request)
|
|
29
|
+
* and requires an allow-listed Oxy package signed with this app's certificate.
|
|
30
|
+
* Anything else gets `null`.
|
|
31
|
+
*
|
|
32
|
+
* What crosses the boundary is a session credential — ordinary, rotatable and
|
|
33
|
+
* server-revocable — and nothing else. The identity key is not in this app's
|
|
34
|
+
* reach at all: Commons holds it, and answers for it through a different
|
|
35
|
+
* provider, under a different permission. That separation is the point.
|
|
28
36
|
*
|
|
29
37
|
* All standard CRUD operations are no-ops; this provider exists solely for the
|
|
30
38
|
* `call()` channel.
|
|
@@ -33,11 +41,27 @@ class OxyDeviceSessionProvider : ContentProvider() {
|
|
|
33
41
|
override fun onCreate(): Boolean = true
|
|
34
42
|
|
|
35
43
|
override fun call(method: String, arg: String?, extras: Bundle?): Bundle? {
|
|
36
|
-
if (method != METHOD_READ) return null
|
|
37
44
|
val ctx = context ?: return null
|
|
38
|
-
if (
|
|
45
|
+
if (method != METHOD_READ && method != METHOD_WRITE && method != METHOD_CLEAR) return null
|
|
46
|
+
OxyCallerPolicy.resolveCaller(ctx, "devicesession.$method") ?: return null
|
|
39
47
|
|
|
40
|
-
return when (
|
|
48
|
+
return when (method) {
|
|
49
|
+
METHOD_READ -> readBundle(OxyDeviceSessionStore.read(ctx))
|
|
50
|
+
METHOD_WRITE -> {
|
|
51
|
+
val deviceId = extras?.getString(OxyDeviceSessionStore.KEY_DEVICE_ID)
|
|
52
|
+
val deviceSecret = extras?.getString(OxyDeviceSessionStore.KEY_DEVICE_SECRET)
|
|
53
|
+
val ok = !deviceId.isNullOrEmpty() && !deviceSecret.isNullOrEmpty() &&
|
|
54
|
+
OxyDeviceSessionStore.write(ctx, deviceId, deviceSecret)
|
|
55
|
+
Bundle().apply { putBoolean(KEY_OK, ok) }
|
|
56
|
+
}
|
|
57
|
+
else -> {
|
|
58
|
+
Bundle().apply { putBoolean(KEY_OK, OxyDeviceSessionStore.clear(ctx)) }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private fun readBundle(read: DeviceSessionRead): Bundle =
|
|
64
|
+
when (read) {
|
|
41
65
|
is DeviceSessionRead.Present -> Bundle().apply {
|
|
42
66
|
putString(KEY_STATUS, STATUS_PRESENT)
|
|
43
67
|
putString(OxyDeviceSessionStore.KEY_DEVICE_ID, read.deviceId)
|
|
@@ -52,20 +76,6 @@ class OxyDeviceSessionProvider : ContentProvider() {
|
|
|
52
76
|
putString(KEY_REASON, read.reason)
|
|
53
77
|
}
|
|
54
78
|
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* True when the calling package shares this app's signing certificate. Uses
|
|
59
|
-
* `checkSignatures` (deprecated but still the simplest correct cross-package
|
|
60
|
-
* signature comparison; returns SIGNATURE_MATCH only for same-cert apps).
|
|
61
|
-
*/
|
|
62
|
-
private fun callerSignatureMatches(ctx: Context): Boolean {
|
|
63
|
-
val caller = callingPackage ?: return false
|
|
64
|
-
return runCatching {
|
|
65
|
-
@Suppress("DEPRECATION")
|
|
66
|
-
ctx.packageManager.checkSignatures(caller, ctx.packageName) == PackageManager.SIGNATURE_MATCH
|
|
67
|
-
}.getOrDefault(false)
|
|
68
|
-
}
|
|
69
79
|
|
|
70
80
|
override fun query(
|
|
71
81
|
uri: Uri,
|
|
@@ -90,6 +100,9 @@ class OxyDeviceSessionProvider : ContentProvider() {
|
|
|
90
100
|
|
|
91
101
|
companion object {
|
|
92
102
|
const val METHOD_READ = "read"
|
|
103
|
+
const val METHOD_WRITE = "write"
|
|
104
|
+
const val METHOD_CLEAR = "clear"
|
|
105
|
+
const val KEY_OK = "ok"
|
|
93
106
|
const val KEY_STATUS = "status"
|
|
94
107
|
const val KEY_REASON = "reason"
|
|
95
108
|
const val STATUS_PRESENT = "present"
|
|
@@ -24,44 +24,36 @@ internal sealed interface DeviceSessionRead {
|
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* The cross-app DeviceSession credential — `deviceId` + `deviceSecret`, and
|
|
27
|
-
* nothing else.
|
|
27
|
+
* nothing else — in a HOST app's own data directory.
|
|
28
28
|
*
|
|
29
|
-
* This is NOT the identity keypair.
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* revoke and any signed-in app can re-publish. They are separate
|
|
33
|
-
* separate providers with separate permissions precisely so an app
|
|
34
|
-
* needs a session is never
|
|
29
|
+
* This is NOT the identity keypair. Commons holds the self-custody private key
|
|
30
|
+
* that signs identity approvals and cannot be re-created, in its own module and
|
|
31
|
+
* behind its own provider; this file holds an ordinary session credential the
|
|
32
|
+
* server can revoke and any signed-in app can re-publish. They are separate
|
|
33
|
+
* files behind separate providers with separate permissions precisely so an app
|
|
34
|
+
* that only needs a session is never near the key.
|
|
35
35
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* same-signature app outside the UID.
|
|
36
|
+
* Every Oxy app has its own UID and its own data directory, so only the hosts
|
|
37
|
+
* (Commons and Accounts) keep this file; [OxyDeviceSessionProvider] is how every
|
|
38
|
+
* other app reads and writes it.
|
|
40
39
|
*/
|
|
41
40
|
internal object OxyDeviceSessionStore {
|
|
42
|
-
/**
|
|
43
|
-
* Global (not package-suffixed) on purpose — the opposite choice from
|
|
44
|
-
* `oxy_background_session`, and for the opposite reason. That store is
|
|
45
|
-
* per-app precisely so one app cannot touch another's widget credential. This
|
|
46
|
-
* one EXISTS to be one value shared by every app in the UID; scoping it per
|
|
47
|
-
* package would give each app its own copy and defeat the whole point.
|
|
48
|
-
*/
|
|
41
|
+
/** One file per host; the other apps reach it through the host's provider. */
|
|
49
42
|
const val PREFS_NAME = "oxy_shared_device_session"
|
|
50
43
|
const val KEY_DEVICE_ID = "deviceId"
|
|
51
44
|
const val KEY_DEVICE_SECRET = "deviceSecret"
|
|
52
45
|
|
|
53
46
|
/**
|
|
54
47
|
* [RecoveryPolicy.RebuildFileOnly] — this store must NEVER escalate to a
|
|
55
|
-
*
|
|
48
|
+
* master-key reset.
|
|
56
49
|
*
|
|
57
50
|
* What it holds is DERIVED: every signed-in app re-publishes the credential
|
|
58
51
|
* from its own durable copy, so losing this file costs at most one interactive
|
|
59
52
|
* sign-in on a device that has no other Oxy app installed. A master-key reset
|
|
60
|
-
* would wipe every other
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* unreachable.
|
|
53
|
+
* would wipe every other encrypted store of this app sharing the alias — in
|
|
54
|
+
* Commons, that includes the identity signer store. Trading an identity copy to
|
|
55
|
+
* save a credential we can simply re-publish is never the right trade, so the
|
|
56
|
+
* escalation is not merely discouraged here, it is unreachable.
|
|
65
57
|
*
|
|
66
58
|
* Consequence worth stating: a stage-1 heal WIPES this file. That surfaces as
|
|
67
59
|
* `Absent`, which is honest — after the wipe the slot really is empty, and the
|
|
@@ -109,7 +101,10 @@ internal object OxyDeviceSessionStore {
|
|
|
109
101
|
}.getOrDefault(false)
|
|
110
102
|
|
|
111
103
|
/** Drop the shared credential. Best-effort; safe to call when already empty. */
|
|
112
|
-
fun clear(context: Context)
|
|
113
|
-
runCatching {
|
|
114
|
-
|
|
104
|
+
fun clear(context: Context): Boolean =
|
|
105
|
+
runCatching {
|
|
106
|
+
val p = prefs(context)
|
|
107
|
+
p.edit().clear().commit()
|
|
108
|
+
p.getString(KEY_DEVICE_ID, null) == null && p.getString(KEY_DEVICE_SECRET, null) == null
|
|
109
|
+
}.getOrDefault(false)
|
|
115
110
|
}
|
|
@@ -8,18 +8,32 @@ import expo.modules.kotlin.modules.Module
|
|
|
8
8
|
import expo.modules.kotlin.modules.ModuleDefinition
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* JS bridge
|
|
11
|
+
* JS bridge to the Oxy identity Commons holds: the client half only.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* apps signed with the shared Oxy release key can resolve it.
|
|
19
|
-
* - `hasShared` / `clearShared` are the check + local teardown helpers.
|
|
13
|
+
* Commons is the ONLY Oxy app that holds the identity private key. Its identity
|
|
14
|
+
* host (a ContentProvider inside Commons, at `${applicationId}.identity`) answers
|
|
15
|
+
* other Oxy apps with the public key, challenge proofs and derived values —
|
|
16
|
+
* never the key, the way apps get tokens from `AccountManager`. This module
|
|
17
|
+
* calls it: the prod authority first, then the dev variant's.
|
|
20
18
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* The call crosses a process boundary between two UIDs, so it needs the
|
|
20
|
+
* `signature`-level `so.oxy.permission.IDENTITY` (every Oxy app declares and
|
|
21
|
+
* requests it through `withOxySharedPermissions`) and a `<queries>` entry for the
|
|
22
|
+
* authority (same plugin). Commons then checks the caller's package and
|
|
23
|
+
* certificate itself.
|
|
24
|
+
*
|
|
25
|
+
* | JS function | Commons method | resolves |
|
|
26
|
+
* |------------------------------------|---------------------|---------------------------------------|
|
|
27
|
+
* | `describe()` | `describe` | `{ v, publicKey }` |
|
|
28
|
+
* | `proveIdentity(challenge)` | `proveIdentity` | `{ publicKey, signature, timestamp }` |
|
|
29
|
+
* | `deriveScopedSeed(info)` | `deriveScopedSeed` | seed hex |
|
|
30
|
+
* | `signSocialReceive(index, digest)` | `signSocialReceive` | `{ signature, publicKey }` |
|
|
31
|
+
*
|
|
32
|
+
* Every failure — Commons not installed, no identity, the caller refused, an
|
|
33
|
+
* exception — resolves `null`, and the JS side (`@oxy.so/protocol`'s
|
|
34
|
+
* `loadCommonsIdentityBridge`) narrows whatever does come back. Plain scalars in,
|
|
35
|
+
* plain `Map`s out; see `so.oxy.session.OxyBackgroundSessionModule` on why this
|
|
36
|
+
* package never uses an Expo `Record`.
|
|
23
37
|
*/
|
|
24
38
|
class OxyIdentityModule : Module() {
|
|
25
39
|
private val context: Context
|
|
@@ -28,57 +42,89 @@ class OxyIdentityModule : Module() {
|
|
|
28
42
|
override fun definition() = ModuleDefinition {
|
|
29
43
|
Name("OxyIdentity")
|
|
30
44
|
|
|
31
|
-
AsyncFunction("
|
|
32
|
-
|
|
45
|
+
AsyncFunction("describe") {
|
|
46
|
+
ask(METHOD_DESCRIBE, null) { bundle ->
|
|
47
|
+
val publicKey = bundle.getString(KEY_PUBLIC_KEY)
|
|
48
|
+
if (publicKey.isNullOrEmpty()) null
|
|
49
|
+
else mapOf(KEY_VERSION to bundle.getInt(KEY_VERSION), KEY_PUBLIC_KEY to publicKey)
|
|
50
|
+
}
|
|
33
51
|
}
|
|
34
52
|
|
|
35
|
-
AsyncFunction("
|
|
36
|
-
|
|
53
|
+
AsyncFunction("proveIdentity") { challenge: String ->
|
|
54
|
+
ask(METHOD_PROVE_IDENTITY, Bundle().apply { putString(KEY_CHALLENGE, challenge) }) { bundle ->
|
|
55
|
+
val publicKey = bundle.getString(KEY_PUBLIC_KEY)
|
|
56
|
+
val signature = bundle.getString(KEY_SIGNATURE)
|
|
57
|
+
val timestamp = bundle.getLong(KEY_TIMESTAMP, 0L)
|
|
58
|
+
if (publicKey.isNullOrEmpty() || signature.isNullOrEmpty() || timestamp <= 0L) null
|
|
59
|
+
else mapOf(
|
|
60
|
+
KEY_PUBLIC_KEY to publicKey,
|
|
61
|
+
KEY_SIGNATURE to signature,
|
|
62
|
+
// A JS number: epoch milliseconds are exact in a double.
|
|
63
|
+
KEY_TIMESTAMP to timestamp.toDouble(),
|
|
64
|
+
)
|
|
65
|
+
}
|
|
37
66
|
}
|
|
38
67
|
|
|
39
|
-
AsyncFunction("
|
|
40
|
-
|
|
68
|
+
AsyncFunction("deriveScopedSeed") { info: String ->
|
|
69
|
+
ask(METHOD_DERIVE_SCOPED_SEED, Bundle().apply { putString(KEY_INFO, info) }) { bundle ->
|
|
70
|
+
bundle.getString(KEY_SEED)?.takeIf { it.isNotEmpty() }
|
|
71
|
+
}
|
|
41
72
|
}
|
|
42
73
|
|
|
43
|
-
AsyncFunction("
|
|
44
|
-
|
|
74
|
+
AsyncFunction("signSocialReceive") { index: Int, digest: String ->
|
|
75
|
+
val extras = Bundle().apply {
|
|
76
|
+
putInt(KEY_INDEX, index)
|
|
77
|
+
putString(KEY_DIGEST, digest)
|
|
78
|
+
}
|
|
79
|
+
ask(METHOD_SIGN_SOCIAL_RECEIVE, extras) { bundle ->
|
|
80
|
+
val signature = bundle.getString(KEY_SIGNATURE)
|
|
81
|
+
val publicKey = bundle.getString(KEY_PUBLIC_KEY)
|
|
82
|
+
if (signature.isNullOrEmpty() || publicKey.isNullOrEmpty()) null
|
|
83
|
+
else mapOf(KEY_SIGNATURE to signature, KEY_PUBLIC_KEY to publicKey)
|
|
84
|
+
}
|
|
45
85
|
}
|
|
46
86
|
}
|
|
47
87
|
|
|
48
88
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
89
|
+
* Call [method] on the first Commons authority that answers, and read the
|
|
90
|
+
* answer with [read]. An authority that is absent, refuses, or throws is
|
|
91
|
+
* skipped; nothing answering is `null`.
|
|
51
92
|
*/
|
|
52
|
-
private fun
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (result != null) return result
|
|
93
|
+
private fun <T> ask(method: String, extras: Bundle?, read: (Bundle) -> T?): T? {
|
|
94
|
+
for (authority in COMMONS_AUTHORITIES) {
|
|
95
|
+
val bundle = runCatching {
|
|
96
|
+
context.contentResolver.call(Uri.parse("content://$authority"), method, null, extras)
|
|
97
|
+
}.getOrNull() ?: continue
|
|
98
|
+
val value = runCatching { read(bundle) }.getOrNull()
|
|
99
|
+
if (value != null) return value
|
|
60
100
|
}
|
|
61
101
|
return null
|
|
62
102
|
}
|
|
63
103
|
|
|
64
|
-
private fun callProvider(authority: String): Map<String, String>? {
|
|
65
|
-
val uri = Uri.parse("content://$authority")
|
|
66
|
-
val bundle: Bundle = context.contentResolver.call(uri, METHOD_GET_SHARED, null, null)
|
|
67
|
-
?: return null
|
|
68
|
-
val priv = bundle.getString(OxyIdentityStore.KEY_PRIVATE) ?: return null
|
|
69
|
-
val pub = bundle.getString(OxyIdentityStore.KEY_PUBLIC) ?: return null
|
|
70
|
-
if (priv.isEmpty() || pub.isEmpty()) return null
|
|
71
|
-
return mapOf("privateKey" to priv, "publicKey" to pub)
|
|
72
|
-
}
|
|
73
|
-
|
|
74
104
|
companion object {
|
|
75
|
-
private const val
|
|
105
|
+
private const val METHOD_DESCRIBE = "describe"
|
|
106
|
+
private const val METHOD_PROVE_IDENTITY = "proveIdentity"
|
|
107
|
+
private const val METHOD_DERIVE_SCOPED_SEED = "deriveScopedSeed"
|
|
108
|
+
private const val METHOD_SIGN_SOCIAL_RECEIVE = "signSocialReceive"
|
|
109
|
+
|
|
110
|
+
private const val KEY_VERSION = "v"
|
|
111
|
+
private const val KEY_PUBLIC_KEY = "publicKey"
|
|
112
|
+
private const val KEY_CHALLENGE = "challenge"
|
|
113
|
+
private const val KEY_SIGNATURE = "signature"
|
|
114
|
+
private const val KEY_TIMESTAMP = "timestamp"
|
|
115
|
+
private const val KEY_INFO = "info"
|
|
116
|
+
private const val KEY_SEED = "seed"
|
|
117
|
+
private const val KEY_INDEX = "index"
|
|
118
|
+
private const val KEY_DIGEST = "digest"
|
|
76
119
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
120
|
+
/**
|
|
121
|
+
* Commons hosts its identity at "${applicationId}.identity": the prod app
|
|
122
|
+
* first, then the dev variant. Keep in step with `withOxySharedPermissions`'
|
|
123
|
+
* `<queries>`, or Android 11+ package visibility hides the provider.
|
|
124
|
+
*/
|
|
125
|
+
private val COMMONS_AUTHORITIES = listOf(
|
|
80
126
|
"so.oxy.commons.identity",
|
|
81
|
-
"so.oxy.commons.dev.identity"
|
|
127
|
+
"so.oxy.commons.dev.identity",
|
|
82
128
|
)
|
|
83
129
|
}
|
|
84
130
|
}
|