@protobase/mobile 0.57.1 → 0.57.3
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 +17 -0
- package/android/src/liveupdate/java/net/protobase/mobile/Bounded.java +30 -0
- package/android/src/liveupdate/java/net/protobase/mobile/BundleCheck.java +53 -0
- package/android/src/liveupdate/java/net/protobase/mobile/BundleRefused.java +9 -0
- package/android/src/liveupdate/java/net/protobase/mobile/BundleRemoval.java +33 -0
- package/android/src/liveupdate/java/net/protobase/mobile/GuardedLiveUpdate.java +138 -15
- package/android/src/liveupdate/java/net/protobase/mobile/Hex.java +42 -0
- package/android/src/liveupdate/java/net/protobase/mobile/ReleaseBinary.java +30 -0
- package/android/src/liveupdate/java/net/protobase/mobile/ReleaseDownload.java +128 -0
- package/android/src/liveupdate/java/net/protobase/mobile/ReleaseManifest.java +98 -0
- package/android/src/liveupdate/java/net/protobase/mobile/ReleaseOrder.java +46 -0
- package/android/src/liveupdate/java/net/protobase/mobile/ReleaseStatement.java +124 -0
- package/android/src/liveupdate/java/net/protobase/mobile/Sha256.java +53 -0
- package/ios/Sources/ProtobaseMobile/BundlesFolder.swift +26 -0
- package/ios/Sources/ProtobaseMobile/GuardedLiveUpdatePlugin.swift +95 -0
- package/ios/Sources/ProtobaseMobile/LiveUpdateDownload.swift +39 -0
- package/ios/Sources/ProtobaseMobile/LiveUpdateLoadCheck.swift +21 -0
- package/ios/Sources/ProtobaseMobile/LiveUpdateMethods.swift +15 -0
- package/ios/Sources/ProtobaseMobile/LiveUpdateSettings.swift +25 -0
- package/ios/Sources/ProtobaseMobile/LocalFileGuard.swift +8 -15
- package/ios/Sources/ProtobaseMobile/PageScriptFilter.swift +5 -3
- package/ios/Sources/ProtobaseMobile/RealPath.swift +8 -0
- package/ios/Sources/ProtobaseMobile/Refusal.swift +12 -0
- package/ios/Sources/ProtobaseMobile/ReleasesUrl.swift +13 -0
- package/ios/Sources/ProtobaseMobile/StockLiveUpdate.swift +58 -0
- package/package.json +2 -2
package/android/build.gradle
CHANGED
|
@@ -24,6 +24,7 @@ android {
|
|
|
24
24
|
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 36
|
|
25
25
|
versionCode 1
|
|
26
26
|
versionName "1.0"
|
|
27
|
+
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
|
27
28
|
}
|
|
28
29
|
buildTypes {
|
|
29
30
|
release {
|
|
@@ -39,6 +40,14 @@ android {
|
|
|
39
40
|
if (liveUpdate != null) {
|
|
40
41
|
sourceSets.main.java.srcDirs += 'src/liveupdate/java'
|
|
41
42
|
}
|
|
43
|
+
// The guard's tests, in the repository only (harness/ runs them): on the JVM, and on a device the same again, on
|
|
44
|
+
// Android's own org.json, beside the guard in a running Capacitor app.
|
|
45
|
+
if (liveUpdate != null && file('src/androidTestLiveupdate').exists()) {
|
|
46
|
+
sourceSets.test.java.srcDirs += 'src/testLiveupdate/java'
|
|
47
|
+
sourceSets.androidTest.java.srcDirs += ['src/testLiveupdate/java', 'src/androidTestLiveupdate/java']
|
|
48
|
+
sourceSets.androidTest.assets.srcDirs += 'src/androidTestLiveupdate/assets'
|
|
49
|
+
sourceSets.androidTest.manifest.srcFile 'src/androidTestLiveupdate/AndroidManifest.xml'
|
|
50
|
+
}
|
|
42
51
|
compileOptions {
|
|
43
52
|
sourceCompatibility JavaVersion.VERSION_21
|
|
44
53
|
targetCompatibility JavaVersion.VERSION_21
|
|
@@ -55,6 +64,14 @@ dependencies {
|
|
|
55
64
|
if (liveUpdate != null) {
|
|
56
65
|
implementation liveUpdate
|
|
57
66
|
}
|
|
67
|
+
// The guard's checks run on the JVM (src/testLiveupdate, or jvm-tests.sh without Android), with the real org.json
|
|
68
|
+
// in place of android.jar's stubs.
|
|
69
|
+
testImplementation 'junit:junit:4.13.2'
|
|
70
|
+
testImplementation 'org.json:json:20250517'
|
|
71
|
+
androidTestImplementation 'androidx.test:runner:1.7.0'
|
|
72
|
+
androidTestImplementation 'androidx.test:core:1.7.0'
|
|
73
|
+
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
|
|
74
|
+
androidTestImplementation 'androidx.appcompat:appcompat:1.7.1'
|
|
58
75
|
}
|
|
59
76
|
|
|
60
77
|
// The plugin list the app loads must match what this build compiles, however the build was started (protobase mobile,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
package net.protobase.mobile;
|
|
2
|
+
|
|
3
|
+
import java.io.ByteArrayOutputStream;
|
|
4
|
+
import java.io.File;
|
|
5
|
+
import java.io.FileInputStream;
|
|
6
|
+
import java.io.IOException;
|
|
7
|
+
import java.io.InputStream;
|
|
8
|
+
|
|
9
|
+
/** Reads a whole stream or file of at most a fixed size, so a planted or served file cannot fill the memory. */
|
|
10
|
+
final class Bounded {
|
|
11
|
+
|
|
12
|
+
private Bounded() {}
|
|
13
|
+
|
|
14
|
+
static byte[] read(InputStream in, int limit, String what) throws IOException, BundleRefused {
|
|
15
|
+
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
|
16
|
+
byte[] buffer = new byte[16 * 1024];
|
|
17
|
+
for (int read; (read = in.read(buffer)) != -1; ) {
|
|
18
|
+
if (out.size() + read > limit) throw new BundleRefused(what + " is larger than " + limit + " bytes");
|
|
19
|
+
out.write(buffer, 0, read);
|
|
20
|
+
}
|
|
21
|
+
return out.toByteArray();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
static byte[] read(File file, int limit, String what) throws IOException, BundleRefused {
|
|
25
|
+
if (!file.isFile()) throw new BundleRefused(what + " is missing");
|
|
26
|
+
try (InputStream in = new FileInputStream(file)) {
|
|
27
|
+
return read(in, limit, what);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
package net.protobase.mobile;
|
|
2
|
+
|
|
3
|
+
import java.io.File;
|
|
4
|
+
import java.io.IOException;
|
|
5
|
+
import java.security.PublicKey;
|
|
6
|
+
import java.util.HashSet;
|
|
7
|
+
import java.util.Set;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Checks a bundle folder on disk before the app runs it: its statement names its id and is signed with the app's key
|
|
11
|
+
* for this binary, its manifest is the one the statement names, every file it lists is there with its checksum and
|
|
12
|
+
* size, and the folder holds nothing else, no link either.
|
|
13
|
+
*/
|
|
14
|
+
final class BundleCheck {
|
|
15
|
+
|
|
16
|
+
private BundleCheck() {}
|
|
17
|
+
|
|
18
|
+
static ReleaseStatement check(File dir, String bundleId, PublicKey key, ReleaseBinary binary) throws BundleRefused {
|
|
19
|
+
try {
|
|
20
|
+
if (!dir.isDirectory() || BundleRemoval.isLink(dir)) throw new BundleRefused("it is no bundle folder");
|
|
21
|
+
ReleaseStatement statement = ReleaseStatement.verify(Bounded.read(new File(dir, ReleaseStatement.FILE), ReleaseStatement.MAX_BYTES, "its statement"), bundleId, key);
|
|
22
|
+
binary.accept(statement);
|
|
23
|
+
ReleaseManifest manifest = ReleaseManifest.verify(Bounded.read(new File(dir, ReleaseManifest.FILE), ReleaseManifest.MAX_BYTES, "its manifest"), statement, dir);
|
|
24
|
+
Set<String> expected = new HashSet<>();
|
|
25
|
+
expected.add(ReleaseStatement.FILE);
|
|
26
|
+
expected.add(ReleaseManifest.FILE);
|
|
27
|
+
for (ReleaseManifest.Item item : manifest.items) expected.add(item.href);
|
|
28
|
+
walk(dir, "", expected, new int[] { expected.size() * 2 });
|
|
29
|
+
for (ReleaseManifest.Item item : manifest.items) {
|
|
30
|
+
File file = new File(dir, item.href);
|
|
31
|
+
if (!file.isFile()) throw new BundleRefused("it lacks " + item.href);
|
|
32
|
+
if (file.length() != item.size || !Sha256.of(file).equals(item.checksum)) throw new BundleRefused("its " + item.href + " is not the file its manifest names");
|
|
33
|
+
}
|
|
34
|
+
return statement;
|
|
35
|
+
} catch (IOException e) {
|
|
36
|
+
throw new BundleRefused("it could not be read");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Every entry below `dir`, as `prefix` + its name: a file must be expected, a folder is walked, a link refuses the
|
|
41
|
+
// bundle. `budget` bounds the entries, so a planted tree cannot keep the check busy.
|
|
42
|
+
private static void walk(File dir, String prefix, Set<String> expected, int[] budget) throws BundleRefused {
|
|
43
|
+
File[] children = dir.listFiles();
|
|
44
|
+
if (children == null) throw new BundleRefused("it could not be read");
|
|
45
|
+
for (File child : children) {
|
|
46
|
+
if (--budget[0] < 0) throw new BundleRefused("its folder holds files its manifest does not list");
|
|
47
|
+
String path = prefix + child.getName();
|
|
48
|
+
if (BundleRemoval.isLink(child)) throw new BundleRefused("its folder holds a link");
|
|
49
|
+
if (child.isDirectory()) walk(child, path + "/", expected, budget);
|
|
50
|
+
else if (!expected.contains(path)) throw new BundleRefused("its folder holds " + path + ", which its manifest does not list");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
package net.protobase.mobile;
|
|
2
|
+
|
|
3
|
+
import java.io.File;
|
|
4
|
+
import java.io.IOException;
|
|
5
|
+
|
|
6
|
+
/** Deletes a folder the guard owns, never following a link out of it: a link is deleted itself. */
|
|
7
|
+
final class BundleRemoval {
|
|
8
|
+
|
|
9
|
+
private BundleRemoval() {}
|
|
10
|
+
|
|
11
|
+
static void delete(File root, File target) {
|
|
12
|
+
if (!target.exists() || !BundlesFolder.contains(root, target)) return;
|
|
13
|
+
deleteTree(target);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
private static void deleteTree(File file) {
|
|
17
|
+
if (file.isDirectory() && !isLink(file)) {
|
|
18
|
+
File[] children = file.listFiles();
|
|
19
|
+
if (children != null) for (File child : children) deleteTree(child);
|
|
20
|
+
}
|
|
21
|
+
file.delete();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Whether `file` is a link: its canonical path is not its parent's followed by its name. One that cannot be resolved counts. */
|
|
25
|
+
static boolean isLink(File file) {
|
|
26
|
+
try {
|
|
27
|
+
File parent = file.getAbsoluteFile().getParentFile();
|
|
28
|
+
return parent == null || !file.getCanonicalPath().equals(new File(parent.getCanonicalFile(), file.getName()).getPath());
|
|
29
|
+
} catch (IOException e) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
package net.protobase.mobile;
|
|
2
2
|
|
|
3
|
+
import android.app.Activity;
|
|
4
|
+
import android.content.SharedPreferences;
|
|
5
|
+
import android.util.Base64;
|
|
3
6
|
import com.getcapacitor.JSObject;
|
|
4
7
|
import com.getcapacitor.Logger;
|
|
5
8
|
import com.getcapacitor.Plugin;
|
|
@@ -8,13 +11,23 @@ import com.getcapacitor.PluginConfig;
|
|
|
8
11
|
import com.getcapacitor.PluginMethod;
|
|
9
12
|
import com.getcapacitor.annotation.CapacitorPlugin;
|
|
10
13
|
import io.capawesome.capacitorjs.plugins.liveupdate.LiveUpdatePlugin;
|
|
14
|
+
import java.io.File;
|
|
15
|
+
import java.io.IOException;
|
|
11
16
|
import java.lang.reflect.Method;
|
|
12
17
|
import java.net.URI;
|
|
13
18
|
import java.net.URISyntaxException;
|
|
19
|
+
import java.security.GeneralSecurityException;
|
|
20
|
+
import java.security.KeyFactory;
|
|
21
|
+
import java.security.PublicKey;
|
|
22
|
+
import java.security.interfaces.RSAPublicKey;
|
|
23
|
+
import java.security.spec.X509EncodedKeySpec;
|
|
14
24
|
import java.util.ArrayList;
|
|
15
25
|
import java.util.Arrays;
|
|
16
26
|
import java.util.Iterator;
|
|
17
27
|
import java.util.List;
|
|
28
|
+
import java.util.UUID;
|
|
29
|
+
import java.util.concurrent.ExecutorService;
|
|
30
|
+
import java.util.concurrent.Executors;
|
|
18
31
|
import java.util.concurrent.atomic.AtomicInteger;
|
|
19
32
|
import org.json.JSONObject;
|
|
20
33
|
|
|
@@ -28,8 +41,13 @@ import org.json.JSONObject;
|
|
|
28
41
|
* upgrade that adds a method leaves no LiveUpdate at all rather than an open one.
|
|
29
42
|
* - The methods the shell needs that take no input clear what page script passed before the stock code reads it.
|
|
30
43
|
* - downloadBundle takes only a manifest from the app's own releases URL, `releasesUrl + "/" + bundleId`, over https
|
|
31
|
-
* or loopback http,
|
|
32
|
-
* and a caller's checksum and signature are dropped. A few per run of the app, so
|
|
44
|
+
* or loopback http, and downloads it itself (ReleaseDownload), bound by the release's statement to the id, the pinned
|
|
45
|
+
* `publicKey`, this binary and its order; a caller's checksum and signature are dropped. A few per run of the app, so
|
|
46
|
+
* a loop cannot drain the data plan.
|
|
47
|
+
* - setNextBundle checks the bundle on disk (BundleCheck), then takes it only above the running bundle's order and not
|
|
48
|
+
* below the highest the app downloaded; a refused bundle that is neither running nor next is deleted, as the stock
|
|
49
|
+
* code deletes an unused one (LiveUpdate.java:803-810). It is the only way to a downloaded bundle: reset, sync and the
|
|
50
|
+
* rest are refused, reload only runs the next bundle and a rollback goes to the built-in one (LiveUpdate.java:1411).
|
|
33
51
|
* - Ids are a single safe component naming a direct child of the bundles folder: the stock code joins them to its path
|
|
34
52
|
* unchecked (LiveUpdate.java:665-667,1299-1302).
|
|
35
53
|
* - Everything else is refused: deleting, resetting, the vendor's cloud, config, channel and ids.
|
|
@@ -46,6 +64,17 @@ public class GuardedLiveUpdate extends LiveUpdatePlugin {
|
|
|
46
64
|
|
|
47
65
|
private static final int MAX_DOWNLOADS = 3;
|
|
48
66
|
private static final AtomicInteger downloads = new AtomicInteger();
|
|
67
|
+
// One download at a time, and one lock over the highest order downloaded and the choice of the next bundle.
|
|
68
|
+
private static final ExecutorService downloader = Executors.newSingleThreadExecutor();
|
|
69
|
+
private static final Object order = new Object();
|
|
70
|
+
// Beside the bundles folder, on the same file system, so a checked download moves into it whole.
|
|
71
|
+
private static final String DOWNLOADS = "_protobase_live_update_downloads";
|
|
72
|
+
private static final String ORDER_PREFS = "ProtobaseLiveUpdate";
|
|
73
|
+
private static final String HIGHEST_ORDER = "highestOrder";
|
|
74
|
+
|
|
75
|
+
private String releasesUrl;
|
|
76
|
+
private PublicKey publicKey;
|
|
77
|
+
private ReleaseBinary binary;
|
|
49
78
|
|
|
50
79
|
@Override
|
|
51
80
|
public void load() {
|
|
@@ -65,9 +94,38 @@ public class GuardedLiveUpdate extends LiveUpdatePlugin {
|
|
|
65
94
|
if (config.getInt("readyTimeout", 0) <= 0) {
|
|
66
95
|
refuseToLoad("plugins.LiveUpdate.readyTimeout must be above 0 in a Protobase app");
|
|
67
96
|
}
|
|
97
|
+
releasesUrl = config.getString("releasesUrl");
|
|
98
|
+
if (!ownSource(releasesUrl)) {
|
|
99
|
+
refuseToLoad("plugins.LiveUpdate.releasesUrl must be an https URL, or http to the device itself, in a Protobase app");
|
|
100
|
+
}
|
|
101
|
+
publicKey = rsaKey(config.getString("publicKey"));
|
|
102
|
+
String fingerprint = config.getString("fingerprint");
|
|
103
|
+
String shellVersion = config.getString("shellVersion");
|
|
104
|
+
if (fingerprint == null || !Hex.isHex(fingerprint, 16) || !ReleaseStatement.isVersion(shellVersion)) {
|
|
105
|
+
refuseToLoad("plugins.LiveUpdate needs the binary's fingerprint and shellVersion in a Protobase app: run protobase mobile again");
|
|
106
|
+
}
|
|
107
|
+
binary = new ReleaseBinary(getContext().getPackageName(), fingerprint, shellVersion);
|
|
108
|
+
// What a download the app did not finish left.
|
|
109
|
+
File downloads = new File(getContext().getFilesDir(), DOWNLOADS);
|
|
110
|
+
BundleRemoval.delete(downloads, downloads);
|
|
68
111
|
super.load();
|
|
69
112
|
}
|
|
70
113
|
|
|
114
|
+
// The key the releases are signed with: an RSA public key as base64 SPKI DER, with or without PEM's lines, as the
|
|
115
|
+
// stock code reads it (LiveUpdate.java:744-755).
|
|
116
|
+
private static PublicKey rsaKey(String value) {
|
|
117
|
+
if (value == null || value.trim().isEmpty()) refuseToLoad("plugins.LiveUpdate.publicKey must be set in a Protobase app");
|
|
118
|
+
String body = value.replace("-----BEGIN PUBLIC KEY-----", "").replace("-----END PUBLIC KEY-----", "").replaceAll("\\s", "");
|
|
119
|
+
try {
|
|
120
|
+
PublicKey key = KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(Base64.decode(body, Base64.DEFAULT)));
|
|
121
|
+
if (!(key instanceof RSAPublicKey)) refuseToLoad("plugins.LiveUpdate.publicKey must be an RSA public key in a Protobase app");
|
|
122
|
+
return key;
|
|
123
|
+
} catch (GeneralSecurityException | IllegalArgumentException e) {
|
|
124
|
+
refuseToLoad("plugins.LiveUpdate.publicKey must be an RSA public key in a Protobase app");
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
71
129
|
// Capacitor then leaves the plugin unregistered, logging only that it failed to load (PluginHandle.java:98-109), so
|
|
72
130
|
// the reason is logged here.
|
|
73
131
|
private static void refuseToLoad(String reason) {
|
|
@@ -80,12 +138,6 @@ public class GuardedLiveUpdate extends LiveUpdatePlugin {
|
|
|
80
138
|
@Override
|
|
81
139
|
@PluginMethod
|
|
82
140
|
public void downloadBundle(PluginCall call) {
|
|
83
|
-
String releasesUrl = getConfig().getString("releasesUrl");
|
|
84
|
-
String publicKey = getConfig().getString("publicKey");
|
|
85
|
-
if (!ownSource(releasesUrl) || publicKey == null || publicKey.trim().isEmpty()) {
|
|
86
|
-
Refusal.rejectMethod(call, "LiveUpdate", "downloadBundle");
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
141
|
String bundleId = call.getString("bundleId");
|
|
90
142
|
boolean allowed =
|
|
91
143
|
"manifest".equals(call.getString("artifactType")) &&
|
|
@@ -99,11 +151,34 @@ public class GuardedLiveUpdate extends LiveUpdatePlugin {
|
|
|
99
151
|
call.reject("LiveUpdate.downloadBundle was called too often in this run of the app");
|
|
100
152
|
return;
|
|
101
153
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
154
|
+
clear(call);
|
|
155
|
+
downloader.execute(() -> download(call, bundleId));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private void download(PluginCall call, String bundleId) {
|
|
159
|
+
File bundles = BundlesFolder.of(getContext());
|
|
160
|
+
File downloads = new File(getContext().getFilesDir(), DOWNLOADS);
|
|
161
|
+
File dir = new File(downloads, UUID.randomUUID().toString());
|
|
162
|
+
try {
|
|
163
|
+
if (!dir.mkdirs() || !(bundles.isDirectory() || bundles.mkdirs())) throw new IOException("Cannot create " + dir);
|
|
164
|
+
String current = bundleIdOf(getBridge().getServerBasePath());
|
|
165
|
+
ReleaseStatement statement = new ReleaseDownload(releasesUrl, publicKey, binary).download(bundleId, dir, current == null ? null : new File(bundles, current), highestOrder());
|
|
166
|
+
File target = new File(bundles, bundleId);
|
|
167
|
+
// The same release downloaded before stays; setNextBundle checks it anyway.
|
|
168
|
+
if (!target.exists() && !dir.renameTo(target)) throw new IOException("Cannot move " + dir);
|
|
169
|
+
synchronized (order) {
|
|
170
|
+
if (ReleaseOrder.compare(statement.order, highestOrder()) > 0) {
|
|
171
|
+
prefs().edit().putString(HIGHEST_ORDER, ReleaseOrder.format(statement.order)).commit();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
call.resolve();
|
|
175
|
+
} catch (BundleRefused e) {
|
|
176
|
+
call.reject("LiveUpdate.downloadBundle refused release " + bundleId + ": " + e.getMessage());
|
|
177
|
+
} catch (IOException e) {
|
|
178
|
+
call.reject("LiveUpdate.downloadBundle could not download release " + bundleId);
|
|
179
|
+
} finally {
|
|
180
|
+
BundleRemoval.delete(downloads, dir);
|
|
181
|
+
}
|
|
107
182
|
}
|
|
108
183
|
|
|
109
184
|
@Override
|
|
@@ -120,8 +195,56 @@ public class GuardedLiveUpdate extends LiveUpdatePlugin {
|
|
|
120
195
|
Refusal.rejectMethod(call, "LiveUpdate", "setNextBundle");
|
|
121
196
|
return;
|
|
122
197
|
}
|
|
123
|
-
|
|
124
|
-
|
|
198
|
+
String id = (String) bundleId;
|
|
199
|
+
File bundles = BundlesFolder.of(getContext());
|
|
200
|
+
synchronized (order) {
|
|
201
|
+
try {
|
|
202
|
+
ReleaseStatement next = BundleCheck.check(new File(bundles, id), id, publicKey, binary);
|
|
203
|
+
ReleaseOrder.acceptNext(next.order, currentOrder(bundles), highestOrder());
|
|
204
|
+
} catch (BundleRefused e) {
|
|
205
|
+
if (!id.equals(bundleIdOf(getBridge().getServerBasePath())) && !id.equals(bundleIdOf(nextPath()))) {
|
|
206
|
+
BundleRemoval.delete(bundles, new File(bundles, id));
|
|
207
|
+
}
|
|
208
|
+
call.reject("LiveUpdate.setNextBundle refused bundle " + id + ": " + e.getMessage());
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
clear(call).put("bundleId", id);
|
|
212
|
+
super.setNextBundle(call);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// The running bundle's order: the built-in one's, or what its statement says, checked against the key.
|
|
217
|
+
private long[] currentOrder(File bundles) throws BundleRefused {
|
|
218
|
+
String current = bundleIdOf(getBridge().getServerBasePath());
|
|
219
|
+
if (current == null) return ReleaseOrder.BUILT_IN;
|
|
220
|
+
try {
|
|
221
|
+
byte[] statement = Bounded.read(new File(new File(bundles, current), ReleaseStatement.FILE), ReleaseStatement.MAX_BYTES, "the running bundle's statement");
|
|
222
|
+
return ReleaseStatement.verify(statement, current, publicKey).order;
|
|
223
|
+
} catch (IOException e) {
|
|
224
|
+
throw new BundleRefused("the running bundle's statement could not be read");
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private long[] highestOrder() {
|
|
229
|
+
return ReleaseOrder.parse(prefs().getString(HIGHEST_ORDER, null));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private SharedPreferences prefs() {
|
|
233
|
+
return getContext().getSharedPreferences(ORDER_PREFS, Activity.MODE_PRIVATE);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Where the stock code keeps the next bundle, Capacitor's server path (LiveUpdate.java:1550, Bridge.java:295-302).
|
|
237
|
+
private String nextPath() {
|
|
238
|
+
return getContext().getSharedPreferences(com.getcapacitor.plugin.WebView.WEBVIEW_PREFS_NAME, Activity.MODE_PRIVATE).getString(com.getcapacitor.plugin.WebView.CAP_SERVER_PATH, null);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// The id of the downloaded bundle at `path`, as the stock code names it (LiveUpdate.java:1214-1262), or null for the
|
|
242
|
+
// built-in one and any path outside the bundles folder.
|
|
243
|
+
private String bundleIdOf(String path) {
|
|
244
|
+
if (path == null || path.isEmpty()) return null;
|
|
245
|
+
File bundles = BundlesFolder.of(getContext());
|
|
246
|
+
String name = new File(path).getName();
|
|
247
|
+
return BundlesFolder.isBundleId(bundles, name) && new File(bundles, name).getAbsolutePath().equals(new File(path).getAbsolutePath()) ? name : null;
|
|
125
248
|
}
|
|
126
249
|
|
|
127
250
|
// Allowed: no input
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
package net.protobase.mobile;
|
|
2
|
+
|
|
3
|
+
/** Lowercase hex, as the server writes hashes and the statement; anything else is refused. */
|
|
4
|
+
final class Hex {
|
|
5
|
+
|
|
6
|
+
private static final char[] DIGITS = "0123456789abcdef".toCharArray();
|
|
7
|
+
|
|
8
|
+
private Hex() {}
|
|
9
|
+
|
|
10
|
+
static String encode(byte[] bytes) {
|
|
11
|
+
char[] text = new char[bytes.length * 2];
|
|
12
|
+
for (int i = 0; i < bytes.length; i++) {
|
|
13
|
+
text[i * 2] = DIGITS[(bytes[i] >> 4) & 0xf];
|
|
14
|
+
text[i * 2 + 1] = DIGITS[bytes[i] & 0xf];
|
|
15
|
+
}
|
|
16
|
+
return new String(text);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
static byte[] decode(String text, String what) throws BundleRefused {
|
|
20
|
+
if (text.length() % 2 != 0) throw new BundleRefused(what + " is no hex");
|
|
21
|
+
byte[] bytes = new byte[text.length() / 2];
|
|
22
|
+
for (int i = 0; i < bytes.length; i++) {
|
|
23
|
+
int high = digit(text.charAt(i * 2));
|
|
24
|
+
int low = digit(text.charAt(i * 2 + 1));
|
|
25
|
+
if (high < 0 || low < 0) throw new BundleRefused(what + " is no hex");
|
|
26
|
+
bytes[i] = (byte) ((high << 4) | low);
|
|
27
|
+
}
|
|
28
|
+
return bytes;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
static boolean isHex(String text, int length) {
|
|
32
|
+
if (text.length() != length) return false;
|
|
33
|
+
for (int i = 0; i < length; i++) if (digit(text.charAt(i)) < 0) return false;
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private static int digit(char c) {
|
|
38
|
+
if (c >= '0' && c <= '9') return c - '0';
|
|
39
|
+
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
|
|
40
|
+
return -1;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
package net.protobase.mobile;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* This binary: its app id, its native fingerprint and the Protobase version of its shell, from the app's package and
|
|
5
|
+
* capacitor.config.json, beyond page script and a live update. It takes a release only for itself.
|
|
6
|
+
*/
|
|
7
|
+
final class ReleaseBinary {
|
|
8
|
+
|
|
9
|
+
final String appId;
|
|
10
|
+
final String fingerprint;
|
|
11
|
+
final String shellVersion;
|
|
12
|
+
|
|
13
|
+
ReleaseBinary(String appId, String fingerprint, String shellVersion) {
|
|
14
|
+
this.appId = appId;
|
|
15
|
+
this.fingerprint = fingerprint;
|
|
16
|
+
this.shellVersion = shellVersion;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Refuses a release for another app or fingerprint, or of a server whose API this shell does not work with: the same
|
|
21
|
+
* major, and below 1.0 the same minor, as the server's `shellUpdateRequired` says.
|
|
22
|
+
*/
|
|
23
|
+
void accept(ReleaseStatement statement) throws BundleRefused {
|
|
24
|
+
if (!appId.equals(statement.appId)) throw new BundleRefused("it is a release of another app");
|
|
25
|
+
if (!fingerprint.equals(statement.fingerprint)) throw new BundleRefused("it is a release for another binary");
|
|
26
|
+
long[] own = ReleaseStatement.majorMinor(shellVersion);
|
|
27
|
+
long[] server = ReleaseStatement.majorMinor(statement.shellVersion);
|
|
28
|
+
if (own[0] != server[0] || (server[0] == 0 && own[1] != server[1])) throw new BundleRefused("it is a release for another shell version");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
package net.protobase.mobile;
|
|
2
|
+
|
|
3
|
+
import java.io.File;
|
|
4
|
+
import java.io.FileInputStream;
|
|
5
|
+
import java.io.FileOutputStream;
|
|
6
|
+
import java.io.IOException;
|
|
7
|
+
import java.io.InputStream;
|
|
8
|
+
import java.io.OutputStream;
|
|
9
|
+
import java.io.UnsupportedEncodingException;
|
|
10
|
+
import java.net.HttpURLConnection;
|
|
11
|
+
import java.net.URI;
|
|
12
|
+
import java.net.URLEncoder;
|
|
13
|
+
import java.security.PublicKey;
|
|
14
|
+
import java.util.HashMap;
|
|
15
|
+
import java.util.Map;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Downloads release `bundleId` from `releasesUrl/<id>?href=<file>` into a folder, in place of the stock downloader,
|
|
19
|
+
* which joins a manifest's hrefs to its path unchecked and checks each file only against its own signature
|
|
20
|
+
* (LiveUpdate.java:817-872 of @capawesome/capacitor-live-update 8.7.1). First the statement, checked against the id,
|
|
21
|
+
* the key, this binary and the highest order the app downloaded; then the manifest, checked against the statement and
|
|
22
|
+
* every href against the folder before any file is fetched; then each file, hashed as it is written and refused unless
|
|
23
|
+
* it is the one the manifest names. A file the running bundle has with the same checksum is copied from it and hashed
|
|
24
|
+
* again, so it is bound by its content, never by its href.
|
|
25
|
+
*/
|
|
26
|
+
final class ReleaseDownload {
|
|
27
|
+
|
|
28
|
+
private static final int TIMEOUT_MS = 30_000;
|
|
29
|
+
|
|
30
|
+
private final String releasesUrl;
|
|
31
|
+
private final PublicKey key;
|
|
32
|
+
private final ReleaseBinary binary;
|
|
33
|
+
|
|
34
|
+
ReleaseDownload(String releasesUrl, PublicKey key, ReleaseBinary binary) {
|
|
35
|
+
this.releasesUrl = releasesUrl;
|
|
36
|
+
this.key = key;
|
|
37
|
+
this.binary = binary;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Fills the empty folder `dir` with release `bundleId`, `current` the running bundle's folder (null for the built-in
|
|
42
|
+
* one), and checks it as a whole. Returns its statement.
|
|
43
|
+
*/
|
|
44
|
+
ReleaseStatement download(String bundleId, File dir, File current, long[] highest) throws BundleRefused, IOException {
|
|
45
|
+
byte[] statementBytes = fetch(bundleId, ReleaseStatement.FILE, ReleaseStatement.MAX_BYTES);
|
|
46
|
+
ReleaseStatement statement = ReleaseStatement.verify(statementBytes, bundleId, key);
|
|
47
|
+
binary.accept(statement);
|
|
48
|
+
if (ReleaseOrder.compare(statement.order, highest) < 0) throw new BundleRefused("it is older than a release the app downloaded");
|
|
49
|
+
byte[] manifestBytes = fetch(bundleId, ReleaseManifest.FILE, ReleaseManifest.MAX_BYTES);
|
|
50
|
+
ReleaseManifest manifest = ReleaseManifest.verify(manifestBytes, statement, dir);
|
|
51
|
+
Map<String, File> own = filesOf(current);
|
|
52
|
+
for (ReleaseManifest.Item item : manifest.items) {
|
|
53
|
+
File target = new File(dir, item.href);
|
|
54
|
+
File parent = target.getParentFile();
|
|
55
|
+
if (!parent.isDirectory() && !parent.mkdirs()) throw new IOException("Cannot create " + parent);
|
|
56
|
+
File copy = own.get(item.checksum);
|
|
57
|
+
if (copy != null && item.checksum.equals(write(new FileInputStream(copy), target, item.size))) continue;
|
|
58
|
+
if (!item.checksum.equals(write(open(bundleId, item.href), target, item.size))) {
|
|
59
|
+
throw new BundleRefused("its " + item.href + " is not the file its manifest names");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
writeBytes(new File(dir, ReleaseStatement.FILE), statementBytes);
|
|
63
|
+
writeBytes(new File(dir, ReleaseManifest.FILE), manifestBytes);
|
|
64
|
+
return BundleCheck.check(dir, bundleId, key, binary);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The running bundle's files by checksum, as its own manifest lists them; none when it has none that parses.
|
|
68
|
+
private static Map<String, File> filesOf(File current) throws IOException {
|
|
69
|
+
Map<String, File> files = new HashMap<>();
|
|
70
|
+
if (current == null) return files;
|
|
71
|
+
ReleaseManifest manifest;
|
|
72
|
+
try {
|
|
73
|
+
manifest = ReleaseManifest.parse(Bounded.read(new File(current, ReleaseManifest.FILE), ReleaseManifest.MAX_BYTES, "its manifest"), current);
|
|
74
|
+
} catch (BundleRefused e) {
|
|
75
|
+
return files;
|
|
76
|
+
}
|
|
77
|
+
for (ReleaseManifest.Item item : manifest.items) {
|
|
78
|
+
File file = new File(current, item.href);
|
|
79
|
+
if (file.isFile() && !BundleRemoval.isLink(file)) files.put(item.checksum, file);
|
|
80
|
+
}
|
|
81
|
+
return files;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Writes at most `size` bytes of `in` to `target`; returns their sha256, or null when there were more or fewer.
|
|
85
|
+
private static String write(InputStream in, File target, long size) throws IOException {
|
|
86
|
+
try (InputStream source = in; OutputStream out = new FileOutputStream(target)) {
|
|
87
|
+
String checksum = Sha256.copy(source, out, size);
|
|
88
|
+
return target.length() == size ? checksum : null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private static void writeBytes(File target, byte[] bytes) throws IOException {
|
|
93
|
+
try (OutputStream out = new FileOutputStream(target)) {
|
|
94
|
+
out.write(bytes);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private byte[] fetch(String bundleId, String href, int limit) throws BundleRefused, IOException {
|
|
99
|
+
try (InputStream in = open(bundleId, href)) {
|
|
100
|
+
return Bounded.read(in, limit, "its " + href);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The file `href` of the release, answered 200 by the releases URL itself: a redirect is no answer.
|
|
105
|
+
private InputStream open(String bundleId, String href) throws BundleRefused, IOException {
|
|
106
|
+
HttpURLConnection connection = (HttpURLConnection) URI.create(releasesUrl + "/" + bundleId + "?href=" + query(href)).toURL().openConnection();
|
|
107
|
+
connection.setInstanceFollowRedirects(false);
|
|
108
|
+
connection.setUseCaches(false);
|
|
109
|
+
connection.setConnectTimeout(TIMEOUT_MS);
|
|
110
|
+
connection.setReadTimeout(TIMEOUT_MS);
|
|
111
|
+
int status = connection.getResponseCode();
|
|
112
|
+
if (status != HttpURLConnection.HTTP_OK) {
|
|
113
|
+
connection.disconnect();
|
|
114
|
+
throw new BundleRefused("the server answered " + status + " for " + href);
|
|
115
|
+
}
|
|
116
|
+
return connection.getInputStream();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The href as a form value: a space as %20, which every server decodes alike, rather than `+`.
|
|
120
|
+
private static String query(String href) {
|
|
121
|
+
try {
|
|
122
|
+
return URLEncoder.encode(href, "UTF-8").replace("+", "%20");
|
|
123
|
+
} catch (UnsupportedEncodingException e) {
|
|
124
|
+
// Every runtime has UTF-8.
|
|
125
|
+
throw new IllegalStateException(e);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|