@protobase/mobile 0.57.2 → 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.
@@ -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,9 @@
1
+ package net.protobase.mobile;
2
+
3
+ /** Why the guard refuses a release or a bundle: its message says so to page script, never a library's own message. */
4
+ final class BundleRefused extends Exception {
5
+
6
+ BundleRefused(String reason) {
7
+ super(reason);
8
+ }
9
+ }
@@ -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, with `publicKey` pinned, so the stock code checks each file's signature (LiveUpdate.java:1633-1654)
32
- * and a caller's checksum and signature are dropped. A few per run of the app, so a loop cannot drain the data plan.
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
- JSObject data = clear(call);
103
- data.put("artifactType", "manifest");
104
- data.put("bundleId", bundleId);
105
- data.put("url", releasesUrl + "/" + bundleId);
106
- super.downloadBundle(call);
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
- clear(call).put("bundleId", bundleId);
124
- super.setNextBundle(call);
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
+ }
@@ -0,0 +1,98 @@
1
+ package net.protobase.mobile;
2
+
3
+ import java.io.File;
4
+ import java.nio.charset.StandardCharsets;
5
+ import java.util.ArrayList;
6
+ import java.util.Collections;
7
+ import java.util.HashSet;
8
+ import java.util.List;
9
+ import java.util.Set;
10
+ import org.json.JSONArray;
11
+ import org.json.JSONException;
12
+ import org.json.JSONObject;
13
+
14
+ /**
15
+ * A release's manifest (`releaseManifestFile` in @protobase/schema): every file by `href`, with its sha256 and size.
16
+ * Each href is checked as the very string the guard joins to a folder, before any file of it is read or written.
17
+ */
18
+ final class ReleaseManifest {
19
+
20
+ static final String FILE = "capawesome-live-update-manifest.json";
21
+ static final int MAX_BYTES = 4 * 1024 * 1024;
22
+ static final int MAX_FILES = 10_000;
23
+ static final long MAX_FILE_BYTES = 64L * 1024 * 1024;
24
+ static final long MAX_TOTAL_BYTES = 256L * 1024 * 1024;
25
+ private static final int MAX_HREF = 1024;
26
+
27
+ static final class Item {
28
+
29
+ final String href;
30
+ final String checksum;
31
+ final long size;
32
+
33
+ Item(String href, String checksum, long size) {
34
+ this.href = href;
35
+ this.checksum = checksum;
36
+ this.size = size;
37
+ }
38
+ }
39
+
40
+ final List<Item> items;
41
+
42
+ private ReleaseManifest(List<Item> items) {
43
+ this.items = Collections.unmodifiableList(items);
44
+ }
45
+
46
+ /** The manifest `bytes` hold, when they are the ones `statement` names, its files to lie inside `dir`. */
47
+ static ReleaseManifest verify(byte[] bytes, ReleaseStatement statement, File dir) throws BundleRefused {
48
+ if (!Sha256.of(bytes).equals(statement.manifest)) throw new BundleRefused("its manifest is not the one its statement names");
49
+ return parse(bytes, dir);
50
+ }
51
+
52
+ /** The manifest `bytes` hold, each href naming a file inside `dir`. */
53
+ static ReleaseManifest parse(byte[] bytes, File dir) throws BundleRefused {
54
+ if (bytes.length > MAX_BYTES) throw new BundleRefused("its manifest is too large");
55
+ try {
56
+ JSONArray array = new JSONArray(new String(bytes, StandardCharsets.UTF_8));
57
+ if (array.length() > MAX_FILES) throw new BundleRefused("its manifest lists too many files");
58
+ List<Item> items = new ArrayList<>();
59
+ Set<String> hrefs = new HashSet<>();
60
+ long total = 0;
61
+ for (int i = 0; i < array.length(); i++) {
62
+ Item item = item(array.getJSONObject(i), dir);
63
+ if (!hrefs.add(item.href)) throw new BundleRefused("its manifest lists " + item.href + " twice");
64
+ total += item.size;
65
+ if (total > MAX_TOTAL_BYTES) throw new BundleRefused("its files are too large");
66
+ items.add(item);
67
+ }
68
+ return new ReleaseManifest(items);
69
+ } catch (JSONException e) {
70
+ throw new BundleRefused("its manifest is malformed");
71
+ }
72
+ }
73
+
74
+ private static Item item(JSONObject entry, File dir) throws JSONException, BundleRefused {
75
+ if (entry.length() != 3) throw new BundleRefused("its manifest has an entry that is not an href, checksum and size");
76
+ String href = entry.getString("href");
77
+ String checksum = entry.getString("checksum");
78
+ Object size = entry.get("sizeInBytes");
79
+ if (!isRelativePath(href) || href.equals(FILE) || href.equals(ReleaseStatement.FILE) || !BundlesFolder.contains(dir, new File(dir, href))) {
80
+ throw new BundleRefused("its manifest names a file outside its folder");
81
+ }
82
+ if (!Hex.isHex(checksum, 64)) throw new BundleRefused("its manifest names no checksum for " + href);
83
+ if (!(size instanceof Integer || size instanceof Long) || ((Number) size).longValue() < 0 || ((Number) size).longValue() > MAX_FILE_BYTES) {
84
+ throw new BundleRefused("its manifest names no size for " + href);
85
+ }
86
+ return new Item(href, checksum, ((Number) size).longValue());
87
+ }
88
+
89
+ // Path components joined by `/`: none empty, `.` or `..`, and no `\` or NUL, so neither an absolute path nor one
90
+ // that climbs, nor one another platform reads otherwise.
91
+ static boolean isRelativePath(String href) {
92
+ if (href.isEmpty() || href.length() > MAX_HREF || href.indexOf('\\') >= 0 || href.indexOf('\0') >= 0) return false;
93
+ for (String part : href.split("/", -1)) {
94
+ if (part.isEmpty() || part.equals(".") || part.equals("..")) return false;
95
+ }
96
+ return true;
97
+ }
98
+ }
@@ -0,0 +1,46 @@
1
+ package net.protobase.mobile;
2
+
3
+ /**
4
+ * Release orders, compared element by element, a missing element counting as 0, so a later server can add one without
5
+ * breaking binaries already shipped. Kept as text such as `1800000000000,0`.
6
+ */
7
+ final class ReleaseOrder {
8
+
9
+ /** The built-in bundle's order. */
10
+ static final long[] BUILT_IN = { 0 };
11
+
12
+ private ReleaseOrder() {}
13
+
14
+ static int compare(long[] a, long[] b) {
15
+ for (int i = 0; i < Math.max(a.length, b.length); i++) {
16
+ long x = i < a.length ? a[i] : 0;
17
+ long y = i < b.length ? b[i] : 0;
18
+ if (x != y) return x < y ? -1 : 1;
19
+ }
20
+ return 0;
21
+ }
22
+
23
+ /**
24
+ * Refuses a bundle of order `next` as the next bundle unless it is above the running one's, `current`, and not below
25
+ * the highest order the app downloaded, `highest`.
26
+ */
27
+ static void acceptNext(long[] next, long[] current, long[] highest) throws BundleRefused {
28
+ if (compare(next, current) <= 0) throw new BundleRefused("it is not newer than the bundle the app runs");
29
+ if (compare(next, highest) < 0) throw new BundleRefused("it is older than a release the app downloaded");
30
+ }
31
+
32
+ static String format(long[] order) {
33
+ StringBuilder text = new StringBuilder();
34
+ for (int i = 0; i < order.length; i++) text.append(i == 0 ? "" : ",").append(order[i]);
35
+ return text.toString();
36
+ }
37
+
38
+ /** The order `text` holds, or the built-in bundle's for none. Only this class writes it. */
39
+ static long[] parse(String text) {
40
+ if (text == null || text.isEmpty()) return BUILT_IN;
41
+ String[] parts = text.split(",");
42
+ long[] order = new long[parts.length];
43
+ for (int i = 0; i < parts.length; i++) order[i] = Long.parseLong(parts[i]);
44
+ return order;
45
+ }
46
+ }
@@ -0,0 +1,124 @@
1
+ package net.protobase.mobile;
2
+
3
+ import java.nio.charset.StandardCharsets;
4
+ import java.security.InvalidKeyException;
5
+ import java.security.NoSuchAlgorithmException;
6
+ import java.security.PublicKey;
7
+ import java.security.Signature;
8
+ import java.security.SignatureException;
9
+ import java.util.Arrays;
10
+ import java.util.HashSet;
11
+ import java.util.Iterator;
12
+ import java.util.Set;
13
+ import java.util.regex.Pattern;
14
+ import org.json.JSONArray;
15
+ import org.json.JSONException;
16
+ import org.json.JSONObject;
17
+
18
+ /**
19
+ * A release's statement (`releaseStatementFile` in @protobase/schema): what binds its manifest to its order, the app,
20
+ * the binary and the shell, signed with the app's key. Only bytes the signature covers are parsed for what they say.
21
+ */
22
+ final class ReleaseStatement {
23
+
24
+ static final String FILE = "protobase-release.json";
25
+ static final int MAX_BYTES = 64 * 1024;
26
+ private static final byte[] CONTEXT = "protobase-mobile-release\n".getBytes(StandardCharsets.UTF_8);
27
+ private static final Set<String> KEYS = new HashSet<>(Arrays.asList("manifest", "order", "appId", "fingerprint", "shellVersion"));
28
+ private static final Pattern VERSION = Pattern.compile("(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)");
29
+ private static final int MAX_ORDER = 8;
30
+
31
+ final String manifest;
32
+ final long[] order;
33
+ final String appId;
34
+ final String fingerprint;
35
+ final String shellVersion;
36
+
37
+ private ReleaseStatement(String manifest, long[] order, String appId, String fingerprint, String shellVersion) {
38
+ this.manifest = manifest;
39
+ this.order = order;
40
+ this.appId = appId;
41
+ this.fingerprint = fingerprint;
42
+ this.shellVersion = shellVersion;
43
+ }
44
+
45
+ /**
46
+ * The statement `file` holds, when it is the one bundle `bundleId` names, the first 16 hex of the sha256 of its bytes
47
+ * and of `key`'s SPKI DER, as `composeRelease` derives it, and `key` signed what it says.
48
+ */
49
+ static ReleaseStatement verify(byte[] file, String bundleId, PublicKey key) throws BundleRefused {
50
+ if (file.length > MAX_BYTES) throw new BundleRefused("its statement is too large");
51
+ if (!Sha256.of(file, key.getEncoded()).substring(0, 16).equals(bundleId)) {
52
+ throw new BundleRefused("its id does not name its statement and the app's key");
53
+ }
54
+ byte[] payload;
55
+ byte[] signature;
56
+ try {
57
+ JSONObject outer = new JSONObject(new String(file, StandardCharsets.UTF_8));
58
+ payload = Hex.decode(outer.getString("payload"), "its statement's payload");
59
+ signature = Hex.decode(outer.getString("signature"), "its statement's signature");
60
+ } catch (JSONException e) {
61
+ throw new BundleRefused("its statement is malformed");
62
+ }
63
+ if (!signed(payload, signature, key)) throw new BundleRefused("its statement is not signed with the app's key");
64
+ try {
65
+ return parse(new JSONObject(new String(payload, StandardCharsets.UTF_8)));
66
+ } catch (JSONException e) {
67
+ throw new BundleRefused("its statement says nothing it can read");
68
+ }
69
+ }
70
+
71
+ private static boolean signed(byte[] payload, byte[] signature, PublicKey key) {
72
+ try {
73
+ Signature verifier = Signature.getInstance("SHA256withRSA");
74
+ verifier.initVerify(key);
75
+ verifier.update(CONTEXT);
76
+ verifier.update(payload);
77
+ return verifier.verify(signature);
78
+ } catch (SignatureException e) {
79
+ // A signature of the wrong length or shape.
80
+ return false;
81
+ } catch (NoSuchAlgorithmException | InvalidKeyException e) {
82
+ // Every runtime has SHA256withRSA, and load() takes only an RSA key.
83
+ throw new IllegalStateException(e);
84
+ }
85
+ }
86
+
87
+ private static ReleaseStatement parse(JSONObject payload) throws JSONException, BundleRefused {
88
+ Set<String> keys = new HashSet<>();
89
+ for (Iterator<String> it = payload.keys(); it.hasNext(); ) keys.add(it.next());
90
+ if (!keys.equals(KEYS)) throw new BundleRefused("its statement does not say exactly what binds it");
91
+ String manifest = payload.getString("manifest");
92
+ String appId = payload.getString("appId");
93
+ String fingerprint = payload.getString("fingerprint");
94
+ String shellVersion = payload.getString("shellVersion");
95
+ if (!Hex.isHex(manifest, 64)) throw new BundleRefused("its statement names no manifest hash");
96
+ if (appId.isEmpty()) throw new BundleRefused("its statement names no app");
97
+ if (!Hex.isHex(fingerprint, 16)) throw new BundleRefused("its statement names no fingerprint");
98
+ if (!VERSION.matcher(shellVersion).matches()) throw new BundleRefused("its statement names no shell version");
99
+ return new ReleaseStatement(manifest, order(payload.getJSONArray("order")), appId, fingerprint, shellVersion);
100
+ }
101
+
102
+ private static long[] order(JSONArray array) throws JSONException, BundleRefused {
103
+ if (array.length() == 0 || array.length() > MAX_ORDER) throw new BundleRefused("its statement names no order");
104
+ long[] order = new long[array.length()];
105
+ for (int i = 0; i < order.length; i++) {
106
+ Object part = array.get(i);
107
+ if (!(part instanceof Integer || part instanceof Long) || ((Number) part).longValue() < 0) {
108
+ throw new BundleRefused("its statement names no order");
109
+ }
110
+ order[i] = ((Number) part).longValue();
111
+ }
112
+ return order;
113
+ }
114
+
115
+ /** The major and minor of an x.y.z version this class accepted. */
116
+ static long[] majorMinor(String version) {
117
+ String[] parts = version.split("\\.");
118
+ return new long[] { Long.parseLong(parts[0]), Long.parseLong(parts[1]) };
119
+ }
120
+
121
+ static boolean isVersion(String version) {
122
+ return version != null && VERSION.matcher(version).matches();
123
+ }
124
+ }
@@ -0,0 +1,53 @@
1
+ package net.protobase.mobile;
2
+
3
+ import java.io.File;
4
+ import java.io.FileInputStream;
5
+ import java.io.IOException;
6
+ import java.io.InputStream;
7
+ import java.io.OutputStream;
8
+ import java.security.MessageDigest;
9
+ import java.security.NoSuchAlgorithmException;
10
+
11
+ /** SHA-256 in lowercase hex, of bytes, of a file, and of a bounded copy from a stream into a file. */
12
+ final class Sha256 {
13
+
14
+ private Sha256() {}
15
+
16
+ static MessageDigest digest() {
17
+ try {
18
+ return MessageDigest.getInstance("SHA-256");
19
+ } catch (NoSuchAlgorithmException e) {
20
+ // Every Java and Android runtime has SHA-256.
21
+ throw new IllegalStateException(e);
22
+ }
23
+ }
24
+
25
+ static String of(byte[]... parts) {
26
+ MessageDigest digest = digest();
27
+ for (byte[] part : parts) digest.update(part);
28
+ return Hex.encode(digest.digest());
29
+ }
30
+
31
+ static String of(File file) throws IOException {
32
+ try (InputStream in = new FileInputStream(file)) {
33
+ return copy(in, null, Long.MAX_VALUE);
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Hashes what `in` holds while writing it to `out` (none: only hashes), refusing more than `limit` bytes. Returns
39
+ * the hash, or null when `in` held more.
40
+ */
41
+ static String copy(InputStream in, OutputStream out, long limit) throws IOException {
42
+ MessageDigest digest = digest();
43
+ byte[] buffer = new byte[64 * 1024];
44
+ long total = 0;
45
+ for (int read; (read = in.read(buffer)) != -1; ) {
46
+ total += read;
47
+ if (total > limit) return null;
48
+ digest.update(buffer, 0, read);
49
+ if (out != null) out.write(buffer, 0, read);
50
+ }
51
+ return Hex.encode(digest.digest());
52
+ }
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/mobile",
3
- "version": "0.57.2",
3
+ "version": "0.57.3",
4
4
  "description": "The Capacitor that protobase mobile builds a Protobase app's iOS and Android projects with, at fixed versions, and the native plugins that keep page script away from the session and give the iOS app its bounce; installed by the CLI on first use.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -22,7 +22,7 @@
22
22
  "android/build.gradle",
23
23
  "icon"
24
24
  ],
25
- "nativeContract": 5,
25
+ "nativeContract": 6,
26
26
  "capacitor": {
27
27
  "ios": {
28
28
  "src": "ios"