@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
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
package so.oxy.security
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.pm.PackageManager
|
|
5
|
+
import android.os.Binder
|
|
6
|
+
import android.os.Build
|
|
7
|
+
import android.util.Log
|
|
8
|
+
import java.security.MessageDigest
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Which app is calling one of this package's ContentProviders, and whether it
|
|
12
|
+
* is an Oxy app.
|
|
13
|
+
*
|
|
14
|
+
* Oxy Android apps do not share a UID, so every cross-app call crosses a real
|
|
15
|
+
* process and permission boundary. The manifest already demands a
|
|
16
|
+
* `signature`-level permission (`so.oxy.permission.DEVICE_SESSION`); inside
|
|
17
|
+
* `call()` this checks again, because a permission is necessary but not
|
|
18
|
+
* sufficient:
|
|
19
|
+
*
|
|
20
|
+
* 1. **The package is an Oxy app** listed in [OXY_PACKAGES]. The caller is taken
|
|
21
|
+
* from `Binder.getCallingUid()`, never from anything the caller sends.
|
|
22
|
+
* 2. **It is signed with this app's certificate**: `hasSigningCertificate`
|
|
23
|
+
* (SHA-256, API 28+), `checkSignatures` below that.
|
|
24
|
+
*
|
|
25
|
+
* Keep [OXY_PACKAGES] identical to the identity host's list in Commons
|
|
26
|
+
* (`modules/oxy-identity-host`, `OxyCallerPolicy.kt`); a test compares them.
|
|
27
|
+
*/
|
|
28
|
+
internal object OxyCallerPolicy {
|
|
29
|
+
private const val TAG = "OxyCallerPolicy"
|
|
30
|
+
|
|
31
|
+
val OXY_PACKAGES: Set<String> = setOf(
|
|
32
|
+
"earth.mention.app",
|
|
33
|
+
"earth.mention.app.dev",
|
|
34
|
+
"onl.alia.app",
|
|
35
|
+
"onl.alia.app.dev",
|
|
36
|
+
"com.allo.app",
|
|
37
|
+
"com.allo.app.dev",
|
|
38
|
+
"com.homiio.android",
|
|
39
|
+
"com.homiio.android.dev",
|
|
40
|
+
"so.oxy.crowdsource",
|
|
41
|
+
"so.oxy.crowdsource.dev",
|
|
42
|
+
"to.peable.app",
|
|
43
|
+
"to.peable.app.dev",
|
|
44
|
+
"so.oxy.atlas",
|
|
45
|
+
"so.oxy.atlas.dev",
|
|
46
|
+
"to.goway.app",
|
|
47
|
+
"to.goway.app.dev",
|
|
48
|
+
"so.oxy.move",
|
|
49
|
+
"so.oxy.move.dev",
|
|
50
|
+
"sh.willo.app",
|
|
51
|
+
"sh.willo.app.dev",
|
|
52
|
+
"now.moovo.app",
|
|
53
|
+
"now.moovo.app.dev",
|
|
54
|
+
"now.moovo.go",
|
|
55
|
+
"now.moovo.go.dev",
|
|
56
|
+
"now.moovo.tracker",
|
|
57
|
+
"now.moovo.tracker.dev",
|
|
58
|
+
"now.moovo.hub",
|
|
59
|
+
"now.moovo.hub.dev",
|
|
60
|
+
"so.oxy.noted",
|
|
61
|
+
"so.oxy.noted.dev",
|
|
62
|
+
"so.oxy.accounts",
|
|
63
|
+
"so.oxy.accounts.dev",
|
|
64
|
+
"so.oxy.commons",
|
|
65
|
+
"so.oxy.commons.dev",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The calling package when it is allow-listed and signed like this app, or
|
|
70
|
+
* null (logged with the package names and the method only, never a value).
|
|
71
|
+
*/
|
|
72
|
+
fun resolveCaller(context: Context, method: String): String? {
|
|
73
|
+
val pm = context.packageManager
|
|
74
|
+
val uid = Binder.getCallingUid()
|
|
75
|
+
val packages = runCatching { pm.getPackagesForUid(uid) }.getOrNull().orEmpty()
|
|
76
|
+
val caller = packages.firstOrNull { it in OXY_PACKAGES && signedLikeSelf(context, it) }
|
|
77
|
+
if (caller == null) {
|
|
78
|
+
Log.w(TAG, "refused $method for ${packages.joinToString().ifEmpty { "uid $uid" }}")
|
|
79
|
+
}
|
|
80
|
+
return caller
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private fun signedLikeSelf(context: Context, pkg: String): Boolean = runCatching {
|
|
84
|
+
val pm = context.packageManager
|
|
85
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
86
|
+
val info = pm.getPackageInfo(context.packageName, PackageManager.GET_SIGNING_CERTIFICATES)
|
|
87
|
+
val signers = info.signingInfo?.apkContentsSigners.orEmpty()
|
|
88
|
+
signers.isNotEmpty() && signers.any { signer ->
|
|
89
|
+
val digest = MessageDigest.getInstance("SHA-256").digest(signer.toByteArray())
|
|
90
|
+
pm.hasSigningCertificate(pkg, digest, PackageManager.CERT_INPUT_SHA256)
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
@Suppress("DEPRECATION")
|
|
94
|
+
pm.checkSignatures(pkg, context.packageName) == PackageManager.SIGNATURE_MATCH
|
|
95
|
+
}
|
|
96
|
+
}.getOrDefault(false)
|
|
97
|
+
}
|
|
@@ -22,13 +22,7 @@ import so.oxy.storage.RecoveryPolicy
|
|
|
22
22
|
* Neither ever rotates the credential — the server does not rotate it either.
|
|
23
23
|
*/
|
|
24
24
|
internal object OxyBackgroundSessionStore {
|
|
25
|
-
/**
|
|
26
|
-
* Suffix is the hosting app's package name. Oxy apps share the `so.oxy.shared`
|
|
27
|
-
* Android UID (and therefore one data directory), so a single global prefs name
|
|
28
|
-
* would let one app that leaves `backgroundSession` off wipe another app's
|
|
29
|
-
* live credential on launch. Scoping by package keeps each app's widget/sync
|
|
30
|
-
* store independent.
|
|
31
|
-
*/
|
|
25
|
+
/** Suffix is the hosting app's package name, so the file says whose credential it holds. */
|
|
32
26
|
private const val PREFS_NAME_PREFIX = "oxy_background_session_"
|
|
33
27
|
|
|
34
28
|
private fun prefsName(context: Context): String = PREFS_NAME_PREFIX + context.packageName
|
|
@@ -61,13 +55,13 @@ internal object OxyBackgroundSessionStore {
|
|
|
61
55
|
|
|
62
56
|
/**
|
|
63
57
|
* [RecoveryPolicy.RebuildFileOnly] — this store must NEVER escalate to a
|
|
64
|
-
*
|
|
58
|
+
* master-key reset.
|
|
65
59
|
*
|
|
66
60
|
* What it holds is disposable: the app re-provisions the credential on its next
|
|
67
61
|
* foreground, so "no credential" is a complete recovery that costs the user
|
|
68
62
|
* nothing (background refreshes render signed out until then). A master-key reset
|
|
69
|
-
* would wipe every other
|
|
70
|
-
*
|
|
63
|
+
* would wipe every other encrypted store of this app sharing the alias — in
|
|
64
|
+
* Commons, including the identity signer store. Trading someone else's unrecoverable
|
|
71
65
|
* data to save data we can simply ask for again is never the right trade, so the
|
|
72
66
|
* escalation is not merely discouraged here, it is unreachable.
|
|
73
67
|
*/
|
|
@@ -18,11 +18,11 @@ import java.security.GeneralSecurityException
|
|
|
18
18
|
*
|
|
19
19
|
* It used to have a second value, `RegenerateSharedMasterKey`, which deleted the
|
|
20
20
|
* androidx master key (`_androidx_security_master_key_`) when a rebuild failed.
|
|
21
|
-
* That key is ONE Keystore entry for the whole
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* OxyHQ/oxy#1388 and must not come back: no store
|
|
25
|
-
*
|
|
21
|
+
* That key is ONE Keystore entry for the whole app and wraps the keyset of every
|
|
22
|
+
* encrypted prefs file in it, so deleting it to heal one file made every other
|
|
23
|
+
* file unreadable (when Oxy apps still shared a UID, every OTHER app's files
|
|
24
|
+
* too). It was removed for OxyHQ/oxy#1388 and must not come back: no store
|
|
25
|
+
* deletes a Keystore entry.
|
|
26
26
|
*/
|
|
27
27
|
internal enum class RecoveryPolicy {
|
|
28
28
|
/**
|
|
@@ -37,9 +37,9 @@ internal enum class RecoveryPolicy {
|
|
|
37
37
|
* The ONE way this package opens a hardware-backed [EncryptedSharedPreferences]
|
|
38
38
|
* file.
|
|
39
39
|
*
|
|
40
|
-
*
|
|
41
|
-
* (`so.oxy.
|
|
42
|
-
* (`so.oxy.session.OxyBackgroundSessionStore`) —
|
|
40
|
+
* The stores built on it — the device session credential
|
|
41
|
+
* (`so.oxy.devicesession.OxyDeviceSessionStore`) and the background session
|
|
42
|
+
* credential (`so.oxy.session.OxyBackgroundSessionStore`) — all need the same two
|
|
43
43
|
* non-obvious properties below. They are implemented once here rather than per
|
|
44
44
|
* store: a second copy would be a copy of exactly the reasoning that is easiest
|
|
45
45
|
* to get wrong, and the failure mode of getting it wrong is silent (every read
|
|
@@ -67,9 +67,9 @@ internal enum class RecoveryPolicy {
|
|
|
67
67
|
* ## Keyset self-heal (CRITICAL)
|
|
68
68
|
*
|
|
69
69
|
* The androidx master key that wraps each file's Tink keyset lives under the
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
70
|
+
* default alias [MasterKey.DEFAULT_MASTER_KEY_ALIAS] — ONE entry for the whole
|
|
71
|
+
* app. When that master key is regenerated (a Keystore invalidation, a restore
|
|
72
|
+
* from a device-to-device transfer), keysets already written on disk stay
|
|
73
73
|
* wrapped under the OLD key. `create()` then fails GCM verification
|
|
74
74
|
* (`AEADBadTag` -> `GeneralSecurityException`, or an unreadable keyset ->
|
|
75
75
|
* `IOException`) on EVERY read/write, and `EncryptedSharedPreferences` never
|
|
@@ -81,14 +81,13 @@ internal enum class RecoveryPolicy {
|
|
|
81
81
|
* keysets. If a fresh keyset still cannot be built, the failure propagates and
|
|
82
82
|
* the `runCatching {}` at every call site degrades to "absent".
|
|
83
83
|
*
|
|
84
|
-
* The master key itself is NEVER deleted here. It is shared by
|
|
85
|
-
* deleting it would make every other
|
|
86
|
-
*
|
|
87
|
-
* Keystore
|
|
88
|
-
* androidx creates a new one on the next `MasterKey.Builder.build()` and each
|
|
84
|
+
* The master key itself is NEVER deleted here. It is shared by every file of the
|
|
85
|
+
* app, so deleting it would make every other one unreadable (and each would then
|
|
86
|
+
* wipe itself on its next open). When the master key is gone — the app's
|
|
87
|
+
* Keystore was wiped with its storage — androidx creates a new one on the next `MasterKey.Builder.build()` and each
|
|
89
88
|
* file heals itself through the file-only rebuild above. What was IN those
|
|
90
89
|
* files is lost with the Keystore either way; for the self-custody identity,
|
|
91
|
-
*
|
|
90
|
+
* Commons' keystore-independent device backup brings it back (docs/identity/device-backup.md).
|
|
92
91
|
*
|
|
93
92
|
* None of this touches the app's device session, which lives in expo-secure-store
|
|
94
93
|
* under a DISTINCT prefs file ("SecureStore") and DISTINCT keystore aliases
|
|
@@ -164,7 +163,7 @@ internal object OxyEncryptedPrefs {
|
|
|
164
163
|
Log.w(
|
|
165
164
|
TAG,
|
|
166
165
|
"'$prefsName' is still unreadable after a file reset ($recovery). Giving up " +
|
|
167
|
-
"without touching the
|
|
166
|
+
"without touching the app-wide master key; its owner re-creates it.",
|
|
168
167
|
cause
|
|
169
168
|
)
|
|
170
169
|
throw cause
|
|
@@ -1,33 +1,32 @@
|
|
|
1
1
|
import ExpoModulesCore
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* iOS
|
|
4
|
+
* iOS stub of the Commons identity bridge.
|
|
5
5
|
*
|
|
6
|
-
* On Apple platforms the
|
|
7
|
-
* `@oxy.so/core`'s `KeyManager`
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* `
|
|
11
|
-
* branches keep using `expo-secure-store` with the keychain group untouched.
|
|
6
|
+
* On Apple platforms the Oxy identity is shared through the Keychain Access
|
|
7
|
+
* Group `group.so.oxy.shared`, which `@oxy.so/core`'s `KeyManager` reads
|
|
8
|
+
* directly; there is no Commons provider to call. Every function resolves `nil`,
|
|
9
|
+
* so `loadCommonsIdentityBridge()` answers "Commons could not help" and
|
|
10
|
+
* `KeyManager`'s iOS branches keep using the keychain group.
|
|
12
11
|
*/
|
|
13
12
|
public class OxyIdentityModule: Module {
|
|
14
13
|
public func definition() -> ModuleDefinition {
|
|
15
14
|
Name("OxyIdentity")
|
|
16
15
|
|
|
17
|
-
AsyncFunction("
|
|
16
|
+
AsyncFunction("describe") { () -> [String: String]? in
|
|
18
17
|
return nil
|
|
19
18
|
}
|
|
20
19
|
|
|
21
|
-
AsyncFunction("
|
|
22
|
-
|
|
20
|
+
AsyncFunction("proveIdentity") { (_ challenge: String) -> [String: String]? in
|
|
21
|
+
return nil
|
|
23
22
|
}
|
|
24
23
|
|
|
25
|
-
AsyncFunction("
|
|
26
|
-
return
|
|
24
|
+
AsyncFunction("deriveScopedSeed") { (_ info: String) -> String? in
|
|
25
|
+
return nil
|
|
27
26
|
}
|
|
28
27
|
|
|
29
|
-
AsyncFunction("
|
|
30
|
-
|
|
28
|
+
AsyncFunction("signSocialReceive") { (_ index: Int, _ digest: String) -> [String: String]? in
|
|
29
|
+
return nil
|
|
31
30
|
}
|
|
32
31
|
}
|
|
33
32
|
}
|
|
@@ -70,8 +70,8 @@ async function detectOfflineHint() {
|
|
|
70
70
|
*
|
|
71
71
|
* Ordered pipeline:
|
|
72
72
|
* 1. Complete an OAuth authorization-code return already on the URL (web)
|
|
73
|
-
* 2. `runSessionColdBoot` — device-secret mint (+ native
|
|
74
|
-
* primary-identity-key lane in `sessionMode: 'identity'`)
|
|
73
|
+
* 2. `runSessionColdBoot` — device-secret mint (+ the native Commons-proof lane,
|
|
74
|
+
* or the primary-identity-key lane in `sessionMode: 'identity'`)
|
|
75
75
|
*
|
|
76
76
|
* Step 1 is ACCOUNT-MODE ONLY: it commits whichever account the IdP resolves,
|
|
77
77
|
* which for an identity-bound client is somebody else's account by construction.
|
|
@@ -29,7 +29,7 @@ var _core = require("@oxy.so/core");
|
|
|
29
29
|
* this loop in a sign-out — so a real revocation still signs out.
|
|
30
30
|
* - No credential, but a key-based lane can still re-establish (the native
|
|
31
31
|
* shared identity, or an identity-bound client's own key) → try that a few
|
|
32
|
-
* times, as the cold boot's
|
|
32
|
+
* times, as the cold boot's Commons-proof step would, then sign out.
|
|
33
33
|
* - Neither → the session is over. Sign out now, as before.
|
|
34
34
|
*
|
|
35
35
|
* Framework-free and clock-injectable so the policy is unit tested without a
|
|
@@ -64,8 +64,8 @@ async function detectOfflineHint() {
|
|
|
64
64
|
*
|
|
65
65
|
* Ordered pipeline:
|
|
66
66
|
* 1. Complete an OAuth authorization-code return already on the URL (web)
|
|
67
|
-
* 2. `runSessionColdBoot` — device-secret mint (+ native
|
|
68
|
-
* primary-identity-key lane in `sessionMode: 'identity'`)
|
|
67
|
+
* 2. `runSessionColdBoot` — device-secret mint (+ the native Commons-proof lane,
|
|
68
|
+
* or the primary-identity-key lane in `sessionMode: 'identity'`)
|
|
69
69
|
*
|
|
70
70
|
* Step 1 is ACCOUNT-MODE ONLY: it commits whichever account the IdP resolves,
|
|
71
71
|
* which for an identity-bound client is somebody else's account by construction.
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* this loop in a sign-out — so a real revocation still signs out.
|
|
24
24
|
* - No credential, but a key-based lane can still re-establish (the native
|
|
25
25
|
* shared identity, or an identity-bound client's own key) → try that a few
|
|
26
|
-
* times, as the cold boot's
|
|
26
|
+
* times, as the cold boot's Commons-proof step would, then sign out.
|
|
27
27
|
* - Neither → the session is over. Sign out now, as before.
|
|
28
28
|
*
|
|
29
29
|
* Framework-free and clock-injectable so the policy is unit tested without a
|
|
@@ -44,8 +44,8 @@ export interface RunProviderColdBootOptions {
|
|
|
44
44
|
*
|
|
45
45
|
* Ordered pipeline:
|
|
46
46
|
* 1. Complete an OAuth authorization-code return already on the URL (web)
|
|
47
|
-
* 2. `runSessionColdBoot` — device-secret mint (+ native
|
|
48
|
-
* primary-identity-key lane in `sessionMode: 'identity'`)
|
|
47
|
+
* 2. `runSessionColdBoot` — device-secret mint (+ the native Commons-proof lane,
|
|
48
|
+
* or the primary-identity-key lane in `sessionMode: 'identity'`)
|
|
49
49
|
*
|
|
50
50
|
* Step 1 is ACCOUNT-MODE ONLY: it commits whichever account the IdP resolves,
|
|
51
51
|
* which for an identity-bound client is somebody else's account by construction.
|
|
@@ -44,8 +44,8 @@ export interface RunProviderColdBootOptions {
|
|
|
44
44
|
*
|
|
45
45
|
* Ordered pipeline:
|
|
46
46
|
* 1. Complete an OAuth authorization-code return already on the URL (web)
|
|
47
|
-
* 2. `runSessionColdBoot` — device-secret mint (+ native
|
|
48
|
-
* primary-identity-key lane in `sessionMode: 'identity'`)
|
|
47
|
+
* 2. `runSessionColdBoot` — device-secret mint (+ the native Commons-proof lane,
|
|
48
|
+
* or the primary-identity-key lane in `sessionMode: 'identity'`)
|
|
49
49
|
*
|
|
50
50
|
* Step 1 is ACCOUNT-MODE ONLY: it commits whichever account the IdP resolves,
|
|
51
51
|
* which for an identity-bound client is somebody else's account by construction.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxy.so/services",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "11.0.0",
|
|
4
4
|
"description": "OxyHQ Expo/React Native SDK — UI components, screens, and native features",
|
|
5
5
|
"main": "lib/commonjs/index.js",
|
|
6
6
|
"module": "lib/module/index.js",
|
|
@@ -155,7 +155,7 @@
|
|
|
155
155
|
},
|
|
156
156
|
"dependencies": {
|
|
157
157
|
"@expo/config-plugins": "57.0.9",
|
|
158
|
-
"@oxy.so/contracts": "^4.
|
|
158
|
+
"@oxy.so/contracts": "^4.4.0",
|
|
159
159
|
"color": "^4.2.3"
|
|
160
160
|
},
|
|
161
161
|
"devDependencies": {
|
|
@@ -163,7 +163,7 @@
|
|
|
163
163
|
"@commitlint/cli": "^21.2.1",
|
|
164
164
|
"@commitlint/config-conventional": "^21.2.0",
|
|
165
165
|
"@expo/vector-icons": "^15.0.3",
|
|
166
|
-
"@oxy.so/core": "
|
|
166
|
+
"@oxy.so/core": "4.0.0",
|
|
167
167
|
"@react-native-async-storage/async-storage": "^2.0.0",
|
|
168
168
|
"@react-native-community/netinfo": "^12.0.1",
|
|
169
169
|
"@react-native/babel-preset": "^0.86.2",
|
|
@@ -178,7 +178,7 @@
|
|
|
178
178
|
"@types/jest": "^30.0.0",
|
|
179
179
|
"@types/node": "^20.19.43",
|
|
180
180
|
"@types/react": "*",
|
|
181
|
-
"@oxy.so/bloom": "^6.
|
|
181
|
+
"@oxy.so/bloom": "^6.2.0",
|
|
182
182
|
"copyfiles": "^2.4.1",
|
|
183
183
|
"expo-clipboard": "~57.0.2",
|
|
184
184
|
"expo-constants": "~57.0.5",
|
|
@@ -212,11 +212,11 @@
|
|
|
212
212
|
"typescript": "^5.9.2",
|
|
213
213
|
"zustand": "^5.0.14"
|
|
214
214
|
},
|
|
215
|
-
"//@oxy.so/bloom": "Bloom
|
|
215
|
+
"//@oxy.so/bloom": "RE-RAISE THIS FLOOR ON EVERY BREAKING BLOOM RELEASE, in the same commit as the root package.json catalog entry and the code that requires it. The breaking position is the MAJOR, so `^3.0.1` means `>=3.0.1 <4.0.0`: 3.x minors are admitted on semver's additive promise, and Bloom 4.0.0 is refused until someone raises this deliberately. The CAP is the part that is load-bearing. An earlier revision of this key recorded the opposite trade -- an uncapped `>=` floor, accepted so that intentionally-breaking Bloom 0.x minors would not be blocked -- and that trade is closed by evidence rather than by preference: an unbounded range is exactly what let `bun install` pair this package with Bloom 1.0.0 and emit no warning at all, and 1.0.0 broke it four ways (a deleted provider, a renamed confirm option, a renamed Chip fill, and two REMOVED SUBPATHS that fail at bundle time, before any typecheck runs). A floor is a claim about what was measured; a cap is the claim that nothing beyond it was. RAISED TO 3.0.1 from `^2.1.0`. Bloom 3.0.0 breaks exactly two things -- `PageHeader` defaults to `presentation=\"floating\"` and stops hiding the title under `transparent`, and `Field disabled` now reaches the control that reads the field context -- and THIS PACKAGE IMPORTS NEITHER: it renders no `PageHeader` and no `Field`, and 3.0 removes no subpath (unlike 2.0.0, which is why that floor cost code changes and this one does not). Its six `transparent` occurrences are CSS colour values, not the PageHeader prop. The cap had to move rather than widen to a union, because leaving `^2.1.0` in place is what made `bun install` resolve a SECOND, nested Bloom 2.12.4 under this package for any app on Bloom 3 -- two copies of a library whose 3.0 contracts (`control-surface`, `TopEdgeProvider`, `ScrollOffsetProvider`) are React contexts, so a provider from one copy cannot be read by a consumer from the other. Mention's `oxyRuntimeCompatibility` test is what caught it; nothing in a typecheck can. RAISED AGAIN TO 3.2.0 from `^3.0.1`, for the same class of reason the 2.x note records, only in the other direction: 3.2.0 ADDS the per-glyph icon subpaths (`@oxy.so/bloom/icons/RiAtLine`) that this package's screens now import instead of the `@oxy.so/bloom/icons` barrel, and a missing subpath fails at bundle time, in the consumer's Metro, long after this package has typechecked and shipped. `^3.0.1` would have admitted Bloom 3.0.1 and 3.1.0, neither of which has those subpaths, so the floor is the only thing standing between a consumer and a red build. Nothing else about 3.1.0 or 3.2.0 is required here; the cap stays at the major. RAISED TO ^4.2.0: FollowButton now delegates its visuals to Bloom media-header FollowButton with loading, textStyle and large size; the new shared UI contracts require one Bloom4 context across Services and its consumer. RAISED TO ^4.31.0: the sign-in and account screens type one-time codes into `InputOtp` with `type=\"alphanumeric\"` (the long sign-in code and backup codes), which 4.31.0 adds; older 4.x drops the letters. RAISED TO ^4.35.0: every password field passes `revealable` to `TextFieldInput` (the eye button, 4.35.0); older 4.x drops the prop silently and the password cannot be shown. The provider also mounts `@oxy.so/bloom/locale` (4.32.0+). RAISED TO ^5.1.0 (services 9.0.0, a major because consumers must move to Bloom 5 with it): Bloom 5.0.0 types `Card`'s `style` without radius, border width or `overflow` (geometry goes through `radius`/`border`/`clipContent`) and stops clipping a Card's children by default, so `BenefitList`'s `overflow: 'visible'` became both a type error and unnecessary; it is removed. The cap MOVES rather than widening to a `^4 || ^5` union, for the reason the 3.0.1 note records: a union lets an app resolve a second, nested Bloom copy and split the React contexts. The floor is 5.1.0 because that is the version measured here (every package builds and tests against it); 5.0.0 was not measured. RAISED TO ^6.0.0 (services 10.0.0, a major because consumers must move to Bloom 6 with it, and because this package's own `FollowButton`/`FollowTargetButton` `size` moves with Bloom's vocabulary): Bloom 6.0.0 removes the duplicate spellings its 5.x types still admitted, and this package used four of them. The control `size` narrows to `xs|sm|md|lg`, so every `small`/`medium`/`large` handed to a Bloom control became `sm`/`md`/`lg`, and the Follow buttons' public `size` became `sm|md|lg` because it is passed straight through to Bloom's media-header `FollowButton`. `Switch` is controlled by `checked`/`onCheckedChange`, not `value`/`onValueChange`. `Button` has no `variant`: each call site names `appearance` and `tone` (the pair the 5.x recipe resolved the variant to, so nothing repaints), `LinkButton`'s colour is `linkTone`, and `PeableButton` renders `InverseButton`. `Card` takes `appearance=\"outline\"` for `variant=\"outlined\"`. The deprecated spellings Bloom 6 still accepts (`isInvalid`, Chip `color`/`variant`) are not breaks and are left alone. The cap MOVES rather than widening to a `^5 || ^6` union, for the reason the 3.0.1 note records: a union lets an app resolve a second, nested Bloom copy and split the React contexts. The floor is 6.0.0 because that is the version measured when this raise was made; the workspace catalog has since moved to ^6.2.0, which adds only the `cover-header` subpath this package does not import, so the floor stays.",
|
|
216
216
|
"peerDependencies": {
|
|
217
217
|
"@expo/vector-icons": "^15.0.3",
|
|
218
218
|
"@oxy.so/bloom": "^6.0.0",
|
|
219
|
-
"@oxy.so/core": "^
|
|
219
|
+
"@oxy.so/core": "^4.0.0",
|
|
220
220
|
"@react-native-async-storage/async-storage": "^2.0.0",
|
|
221
221
|
"@react-native-community/netinfo": ">=11.4.1",
|
|
222
222
|
"@tanstack/query-async-storage-persister": "^5.101",
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config plugin: withOxySharedPermissions (EVERY Oxy Android app).
|
|
3
|
+
*
|
|
4
|
+
* Oxy Android apps do not share a UID. They share the Oxy identity and the
|
|
5
|
+
* device session with Commons (and Accounts) through signature-protected
|
|
6
|
+
* ContentProvider `call()` IPC, the way apps use `AccountManager`. Crossing a UID
|
|
7
|
+
* needs two `signature`-level permissions:
|
|
8
|
+
*
|
|
9
|
+
* - `so.oxy.permission.IDENTITY` — Commons' identity host
|
|
10
|
+
* (`so.oxy.commons[.dev].identity`): public key, challenge proofs, scoped
|
|
11
|
+
* derivations. Never the private key.
|
|
12
|
+
* - `so.oxy.permission.DEVICE_SESSION` — the device-session hosts
|
|
13
|
+
* (`so.oxy.commons[.dev].devicesession`, `so.oxy.accounts[.dev].devicesession`).
|
|
14
|
+
*
|
|
15
|
+
* This plugin makes the app both DECLARE and REQUEST both, and adds `<queries>`
|
|
16
|
+
* for every host authority:
|
|
17
|
+
*
|
|
18
|
+
* - Declared by every app, not only by the hosts: a signature permission is
|
|
19
|
+
* granted when the requesting app is installed, if the declaring app is
|
|
20
|
+
* already there. With every app declaring it, install order never matters.
|
|
21
|
+
* Android accepts the same declaration from several packages signed with one
|
|
22
|
+
* certificate, and rejects it from a differently-signed one
|
|
23
|
+
* (`INSTALL_FAILED_DUPLICATE_PERMISSION`), which is the protection we want.
|
|
24
|
+
* Every declaration must be identical, which is why this ONE plugin owns
|
|
25
|
+
* them; the provider plugins do not declare anything.
|
|
26
|
+
* - `<queries>`: Android 11+ package visibility hides a provider the app has
|
|
27
|
+
* not declared an interest in, and `ContentResolver.call` then silently finds
|
|
28
|
+
* nothing.
|
|
29
|
+
*
|
|
30
|
+
* `signature` is the whole trust boundary at the manifest level; each provider
|
|
31
|
+
* also checks the calling package and its certificate itself.
|
|
32
|
+
*/
|
|
33
|
+
const { withAndroidManifest } = require('@expo/config-plugins');
|
|
34
|
+
|
|
35
|
+
const IDENTITY_PERMISSION = 'so.oxy.permission.IDENTITY';
|
|
36
|
+
const DEVICE_SESSION_PERMISSION = 'so.oxy.permission.DEVICE_SESSION';
|
|
37
|
+
const PERMISSIONS = [IDENTITY_PERMISSION, DEVICE_SESSION_PERMISSION];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Every host authority an Oxy app calls. Keep in step with `COMMONS_AUTHORITIES`
|
|
41
|
+
* in `OxyIdentityModule.kt` and `HOST_AUTHORITIES` in `OxyDeviceSessionModule.kt`
|
|
42
|
+
* (a test compares them).
|
|
43
|
+
*/
|
|
44
|
+
const HOST_AUTHORITIES = [
|
|
45
|
+
'so.oxy.commons.identity',
|
|
46
|
+
'so.oxy.commons.dev.identity',
|
|
47
|
+
'so.oxy.commons.devicesession',
|
|
48
|
+
'so.oxy.commons.dev.devicesession',
|
|
49
|
+
'so.oxy.accounts.devicesession',
|
|
50
|
+
'so.oxy.accounts.dev.devicesession',
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
function withOxySharedPermissions(config) {
|
|
54
|
+
return withAndroidManifest(config, (modConfig) => {
|
|
55
|
+
const manifest = modConfig.modResults.manifest;
|
|
56
|
+
|
|
57
|
+
manifest.permission = manifest.permission ?? [];
|
|
58
|
+
manifest['uses-permission'] = manifest['uses-permission'] ?? [];
|
|
59
|
+
for (const name of PERMISSIONS) {
|
|
60
|
+
if (!manifest.permission.some((p) => p.$['android:name'] === name)) {
|
|
61
|
+
manifest.permission.push({
|
|
62
|
+
$: { 'android:name': name, 'android:protectionLevel': 'signature' },
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
if (!manifest['uses-permission'].some((p) => p.$['android:name'] === name)) {
|
|
66
|
+
manifest['uses-permission'].push({ $: { 'android:name': name } });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
manifest.queries = manifest.queries ?? [];
|
|
71
|
+
if (manifest.queries.length === 0) {
|
|
72
|
+
manifest.queries.push({});
|
|
73
|
+
}
|
|
74
|
+
const queries = manifest.queries[0];
|
|
75
|
+
queries.provider = queries.provider ?? [];
|
|
76
|
+
for (const authority of HOST_AUTHORITIES) {
|
|
77
|
+
if (!queries.provider.some((p) => p.$['android:authorities'] === authority)) {
|
|
78
|
+
queries.provider.push({ $: { 'android:authorities': authority } });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return modConfig;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = withOxySharedPermissions;
|
|
87
|
+
module.exports.IDENTITY_PERMISSION = IDENTITY_PERMISSION;
|
|
88
|
+
module.exports.DEVICE_SESSION_PERMISSION = DEVICE_SESSION_PERMISSION;
|
|
89
|
+
module.exports.HOST_AUTHORITIES = HOST_AUTHORITIES;
|
|
@@ -1,86 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Config plugin: withSharedDeviceSessionProvider (
|
|
2
|
+
* Config plugin: withSharedDeviceSessionProvider (the device-session HOSTS:
|
|
3
|
+
* Commons and Accounts, prod and dev).
|
|
3
4
|
*
|
|
4
|
-
* The shared DeviceSession credential is how
|
|
5
|
+
* The shared DeviceSession credential is how the official Oxy apps end up on
|
|
5
6
|
* ONE `DeviceSession` — and therefore one globally active account context —
|
|
6
|
-
*
|
|
7
|
-
* the
|
|
7
|
+
* without any of them touching the identity key. Oxy apps do not share a UID,
|
|
8
|
+
* so the credential lives in the hosts' own storage, and this plugin declares
|
|
9
|
+
* the provider every other app reads and writes it through:
|
|
10
|
+
* `OxyDeviceSessionProvider` at `${applicationId}.devicesession` (AGP
|
|
11
|
+
* substitutes `${applicationId}` at build), guarded by the `signature`-level
|
|
12
|
+
* `so.oxy.permission.DEVICE_SESSION`.
|
|
8
13
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* it — the trust boundary is the signing key, not the deprecated `sharedUserId`.
|
|
13
|
-
* - Requests that same permission so this app can also read cross-authority.
|
|
14
|
-
* - Declares `OxyDeviceSessionProvider` at authority
|
|
15
|
-
* `${applicationId}.devicesession` (AGP substitutes `${applicationId}` at
|
|
16
|
-
* build), guarded by that permission. The provider is READ-only across the
|
|
17
|
-
* process boundary: a sibling may join the device session, never overwrite it.
|
|
18
|
-
* - Adds `<queries>` entries so Android 11+ package-visibility filtering never
|
|
19
|
-
* hides the sibling providers from the resolver.
|
|
14
|
+
* It declares NO permission itself: every Oxy app, host or not, declares and
|
|
15
|
+
* requests both Oxy permissions through `withOxySharedPermissions`, so the
|
|
16
|
+
* declarations stay identical. Apply that plugin too.
|
|
20
17
|
*
|
|
21
|
-
* WHICH APPS USE THIS PLUGIN: exactly the apps listed in
|
|
22
|
-
*
|
|
23
|
-
* data directory, so they all serve the SAME file and the sweep in
|
|
24
|
-
* `OxyDeviceSessionModule` is deterministic whichever one answers. An app that is
|
|
25
|
-
* same-signature but outside that UID must use `withSharedDeviceSessionReader`
|
|
26
|
-
* instead — hosting from there would publish a second, independent copy of "the
|
|
27
|
-
* shared credential" and make the winner depend on list order.
|
|
28
|
-
*
|
|
29
|
-
* Keep this list identical to `PROVIDER_AUTHORITIES` in
|
|
30
|
-
* `android/src/main/java/so/oxy/devicesession/OxyDeviceSessionModule.kt`.
|
|
31
|
-
*
|
|
32
|
-
* This is deliberately a SEPARATE permission and a SEPARATE provider from
|
|
33
|
-
* `withSharedIdentityProvider`. Two secrets with different blast radii: the
|
|
34
|
-
* identity key is self-custody and irreplaceable, the device credential is
|
|
35
|
-
* server-revocable and re-publishable. An app that only needs a session gets only
|
|
36
|
-
* the second one.
|
|
18
|
+
* WHICH APPS USE THIS PLUGIN: exactly the apps whose authorities are listed in
|
|
19
|
+
* `HOST_AUTHORITIES` of `OxyDeviceSessionModule.kt` (a test compares them).
|
|
37
20
|
*/
|
|
38
21
|
const { withAndroidManifest } = require('@expo/config-plugins');
|
|
39
22
|
|
|
40
|
-
const
|
|
23
|
+
const DEVICE_SESSION_PERMISSION = 'so.oxy.permission.DEVICE_SESSION';
|
|
41
24
|
const PROVIDER_CLASS = 'so.oxy.devicesession.OxyDeviceSessionProvider';
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
25
|
+
|
|
26
|
+
/** The authorities this plugin can end up hosting, one per host app. */
|
|
27
|
+
const HOST_AUTHORITIES = [
|
|
45
28
|
'so.oxy.commons.devicesession',
|
|
46
29
|
'so.oxy.commons.dev.devicesession',
|
|
30
|
+
'so.oxy.accounts.devicesession',
|
|
31
|
+
'so.oxy.accounts.dev.devicesession',
|
|
47
32
|
];
|
|
48
33
|
|
|
49
|
-
|
|
34
|
+
function withSharedDeviceSessionProvider(config) {
|
|
50
35
|
return withAndroidManifest(config, (modConfig) => {
|
|
51
36
|
const manifest = modConfig.modResults.manifest;
|
|
52
|
-
|
|
53
|
-
// 1. Define the signature-level permission.
|
|
54
|
-
manifest['permission'] = manifest['permission'] ?? [];
|
|
55
|
-
if (!manifest['permission'].some((p) => p.$['android:name'] === READ_DEVICE_SESSION_PERMISSION)) {
|
|
56
|
-
manifest['permission'].push({
|
|
57
|
-
$: {
|
|
58
|
-
'android:name': READ_DEVICE_SESSION_PERMISSION,
|
|
59
|
-
'android:protectionLevel': 'signature',
|
|
60
|
-
},
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// 2. Request it (a hub app reads its siblings too — prod ⇆ dev variant).
|
|
65
|
-
manifest['uses-permission'] = manifest['uses-permission'] ?? [];
|
|
66
|
-
if (!manifest['uses-permission'].some((p) => p.$['android:name'] === READ_DEVICE_SESSION_PERMISSION)) {
|
|
67
|
-
manifest['uses-permission'].push({ $: { 'android:name': READ_DEVICE_SESSION_PERMISSION } });
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// 3. Make the sibling provider authorities visible under package filtering.
|
|
71
|
-
manifest['queries'] = manifest['queries'] ?? [];
|
|
72
|
-
if (manifest['queries'].length === 0) {
|
|
73
|
-
manifest['queries'].push({});
|
|
74
|
-
}
|
|
75
|
-
const queries = manifest['queries'][0];
|
|
76
|
-
queries.provider = queries.provider ?? [];
|
|
77
|
-
for (const authority of PROVIDER_AUTHORITIES) {
|
|
78
|
-
if (!queries.provider.some((p) => p.$['android:authorities'] === authority)) {
|
|
79
|
-
queries.provider.push({ $: { 'android:authorities': authority } });
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// 4. Host the provider.
|
|
84
37
|
const app = manifest.application?.[0];
|
|
85
38
|
if (!app) {
|
|
86
39
|
throw new Error('withSharedDeviceSessionProvider: AndroidManifest has no <application>');
|
|
@@ -92,11 +45,13 @@ module.exports = function withSharedDeviceSessionProvider(config) {
|
|
|
92
45
|
'android:name': PROVIDER_CLASS,
|
|
93
46
|
'android:authorities': '${applicationId}.devicesession',
|
|
94
47
|
'android:exported': 'true',
|
|
95
|
-
'android:permission':
|
|
48
|
+
'android:permission': DEVICE_SESSION_PERMISSION,
|
|
96
49
|
},
|
|
97
50
|
});
|
|
98
51
|
}
|
|
99
|
-
|
|
100
52
|
return modConfig;
|
|
101
53
|
});
|
|
102
|
-
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = withSharedDeviceSessionProvider;
|
|
57
|
+
module.exports.HOST_AUTHORITIES = HOST_AUTHORITIES;
|
|
@@ -91,8 +91,8 @@ export interface RunProviderColdBootOptions {
|
|
|
91
91
|
*
|
|
92
92
|
* Ordered pipeline:
|
|
93
93
|
* 1. Complete an OAuth authorization-code return already on the URL (web)
|
|
94
|
-
* 2. `runSessionColdBoot` — device-secret mint (+ native
|
|
95
|
-
* primary-identity-key lane in `sessionMode: 'identity'`)
|
|
94
|
+
* 2. `runSessionColdBoot` — device-secret mint (+ the native Commons-proof lane,
|
|
95
|
+
* or the primary-identity-key lane in `sessionMode: 'identity'`)
|
|
96
96
|
*
|
|
97
97
|
* Step 1 is ACCOUNT-MODE ONLY: it commits whichever account the IdP resolves,
|
|
98
98
|
* which for an identity-bound client is somebody else's account by construction.
|
|
@@ -102,7 +102,7 @@ describe('token loss recovery', () => {
|
|
|
102
102
|
});
|
|
103
103
|
|
|
104
104
|
it('re-establishes through the shared identity when the credential is gone, like the cold boot does', async () => {
|
|
105
|
-
const h = harness([null, 'from-
|
|
105
|
+
const h = harness([null, 'from-commons-proof']);
|
|
106
106
|
h.state.credential = false;
|
|
107
107
|
h.state.keyed = true;
|
|
108
108
|
const recovery = createTokenLossRecovery(h.deps);
|
|
@@ -111,7 +111,7 @@ describe('token loss recovery', () => {
|
|
|
111
111
|
await runToCompletion(recovery);
|
|
112
112
|
|
|
113
113
|
expect(h.signOutLocally).not.toHaveBeenCalled();
|
|
114
|
-
expect(h.state.token).toBe('from-
|
|
114
|
+
expect(h.state.token).toBe('from-commons-proof');
|
|
115
115
|
});
|
|
116
116
|
|
|
117
117
|
it('gives the key-based lane a bounded number of tries, then signs out', async () => {
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* this loop in a sign-out — so a real revocation still signs out.
|
|
22
22
|
* - No credential, but a key-based lane can still re-establish (the native
|
|
23
23
|
* shared identity, or an identity-bound client's own key) → try that a few
|
|
24
|
-
* times, as the cold boot's
|
|
24
|
+
* times, as the cold boot's Commons-proof step would, then sign out.
|
|
25
25
|
* - Neither → the session is over. Sign out now, as before.
|
|
26
26
|
*
|
|
27
27
|
* Framework-free and clock-injectable so the policy is unit tested without a
|