@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.
Files changed (27) hide show
  1. package/android/build.gradle +1 -1
  2. package/android/src/main/AndroidManifest.xml +6 -6
  3. package/android/src/main/java/so/oxy/devicesession/OxyDeviceSessionModule.kt +106 -41
  4. package/android/src/main/java/so/oxy/devicesession/OxyDeviceSessionProvider.kt +45 -32
  5. package/android/src/main/java/so/oxy/devicesession/OxyDeviceSessionStore.kt +22 -27
  6. package/android/src/main/java/so/oxy/identity/OxyIdentityModule.kt +89 -43
  7. package/android/src/main/java/so/oxy/security/OxyCallerPolicy.kt +97 -0
  8. package/android/src/main/java/so/oxy/session/OxyBackgroundSessionStore.kt +4 -10
  9. package/android/src/main/java/so/oxy/storage/OxyEncryptedPrefs.kt +17 -18
  10. package/ios/OxyIdentityModule.swift +13 -14
  11. package/lib/commonjs/ui/boot/runProviderColdBoot.js +2 -2
  12. package/lib/commonjs/ui/session/tokenLossRecovery.js +1 -1
  13. package/lib/module/ui/boot/runProviderColdBoot.js +2 -2
  14. package/lib/module/ui/session/tokenLossRecovery.js +1 -1
  15. package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts +2 -2
  16. package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts +2 -2
  17. package/package.json +6 -6
  18. package/plugins/withOxySharedPermissions.js +89 -0
  19. package/plugins/withSharedDeviceSessionProvider.js +26 -71
  20. package/src/ui/boot/runProviderColdBoot.ts +2 -2
  21. package/src/ui/session/__tests__/tokenLossRecovery.test.ts +2 -2
  22. package/src/ui/session/tokenLossRecovery.ts +1 -1
  23. package/android/src/main/java/so/oxy/identity/OxyIdentityProvider.kt +0 -77
  24. package/android/src/main/java/so/oxy/identity/OxyIdentityStore.kt +0 -66
  25. package/plugins/withSharedDeviceSessionReader.js +0 -57
  26. package/plugins/withSharedIdentityProvider.js +0 -83
  27. package/plugins/withSharedIdentityReader.js +0 -46
@@ -16,6 +16,6 @@ android {
16
16
 
17
17
  dependencies {
18
18
  // Hardware-backed EncryptedSharedPreferences (MasterKey AES256_GCM) for the
19
- // cross-app shared identity keypair.
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 signature-protected <permission> and the
4
- <provider> that hosts the shared identity are declared per-app by the
5
- Commons config plugin (`withSharedIdentityProvider.js`), NOT here — only
6
- Commons (the identity vault) should host the provider, and its authority is
7
- the app's own ${applicationId}.identity. Reader apps add only the matching
8
- <uses-permission> via `withSharedIdentityReader.js`.
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
- * - `read` resolves the credential this DEVICE holds, from the sibling providers
14
- * first and this app's own store second (see [readShared] for why that order).
15
- * - `write` publishes into this app's own store only, and reports whether a
16
- * read-back confirmed it.
17
- * - `clear` drops this app's copy.
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
- OxyDeviceSessionStore.write(context, deviceId, deviceSecret)
69
+ publish(deviceId, deviceSecret)
58
70
  }
59
71
 
60
72
  AsyncFunction("clear") {
61
- OxyDeviceSessionStore.clear(context)
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
- * Providers FIRST, this app's own store second.
67
- *
68
- * Under `android:sharedUserId="so.oxy.shared"` every Oxy app sees one data
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 PROVIDER_AUTHORITIES) {
83
- when (val read = callProvider(authority)) {
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 sibling provider. `null` means "nothing to learn from this 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
- // An older sibling that predates this protocol. It said something we do not
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
- * Sibling authorities to sweep, in order. Each official Oxy Android app hosts
156
- * the provider at `${applicationId}.devicesession` via the
157
- * `withSharedDeviceSession` config plugin, and both the prod and `.dev`
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
- * MAINTAINED LIST, and the constraint on it is specific: every entry must be
161
- * an app inside the `so.oxy.shared` UID. UID members all serve ONE file, so
162
- * the sweep is deterministic no matter which of them answers first. Adding an
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 PROVIDER_AUTHORITIES = listOf(
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
- * Cross-process READ surface for the shared DeviceSession credential, declared
13
- * per-app by the `withSharedDeviceSession` config plugin at authority
14
- * `${applicationId}.devicesession` behind a `signature`-level permission.
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
- * The signature check below is belt-and-suspenders on top of that permission:
17
- * even if the manifest gate were ever misconfigured, a differently-signed caller
18
- * still gets nothing. Same-signature is the whole trust boundary — an
19
- * incorrectly-signed app cannot read this slot, and there is no other way in.
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
- * READ-ONLY across the process boundary, deliberately. A caller may join the
22
- * device's session; it may not seed or overwrite another app's. Writes happen
23
- * only in-process via [OxyDeviceSessionModule], against this app's own store.
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
- * What crosses the boundary is a session credential — never the identity
26
- * keypair, which lives behind `so.oxy.identity.OxyIdentityProvider` under a
27
- * different permission. That separation is the point.
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 (!callerSignatureMatches(ctx)) return null
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 (val read = OxyDeviceSessionStore.read(ctx)) {
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. `so.oxy.identity.OxyIdentityStore` holds the
30
- * self-custody private key that signs identity approvals and cannot be
31
- * re-created; this file holds an ordinary session credential the server can
32
- * revoke and any signed-in app can re-publish. They are separate files behind
33
- * separate providers with separate permissions precisely so an app that only
34
- * needs a session is never handed the key.
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
- * Under `android:sharedUserId="so.oxy.shared"` every Oxy app sees ONE data
37
- * directory, so this file is literally shared between them — that is the primary
38
- * transport, and [OxyDeviceSessionProvider] extends the same bytes to a
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
- * UID-shared master-key reset.
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 Oxy store sharing the alias — including the
61
- * self-custody identity keypair, which is IRREPLACEABLE. Trading someone's
62
- * unrecoverable identity to save a credential we can simply re-publish is never
63
- * the right trade, so the escalation is not merely discouraged here, it is
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 { prefs(context).edit().clear().commit() }
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 for the cross-app shared Oxy identity.
11
+ * JS bridge to the Oxy identity Commons holds: the client half only.
12
12
  *
13
- * - `putShared` persists the keypair into THIS app's own hardware-backed
14
- * EncryptedSharedPreferences (only Commons calls this).
15
- * - `getShared` resolves the keypair from the LOCAL store first (Commons reading
16
- * itself), then from the Commons ContentProvider — prod authority, then the
17
- * dev-variant authority. The `signature` permission on the provider means only
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
- * Every failure resolves to null / no-op so the JS layer degrades to the app's
22
- * normal interactive sign-in path.
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("getShared") {
32
- readShared()
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("putShared") { privateKey: String, publicKey: String ->
36
- OxyIdentityStore.write(context, privateKey, publicKey)
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("hasShared") {
40
- readShared() != null
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("clearShared") {
44
- OxyIdentityStore.clear(context)
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
- * Local EncryptedSharedPreferences first, then the Commons provider (prod
50
- * authority, then dev). Returns null on any failure.
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 readShared(): Map<String, String>? {
53
- runCatching { OxyIdentityStore.read(context) }.getOrNull()?.let { (priv, pub) ->
54
- return mapOf("privateKey" to priv, "publicKey" to pub)
55
- }
56
-
57
- for (authority in PROVIDER_AUTHORITIES) {
58
- val result = runCatching { callProvider(authority) }.getOrNull()
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 METHOD_GET_SHARED = "getShared"
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
- // Commons hosts the provider at "${applicationId}.identity". Try the prod
78
- // app id first, then the dev variant ("so.oxy.commons.dev").
79
- private val PROVIDER_AUTHORITIES = listOf(
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
  }