@protobase/mobile 0.57.2 → 0.57.4

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.util.Arrays;
4
+ import java.util.regex.Pattern;
5
+
6
+ /**
7
+ * The bundles the stock plugin blocked after rolling them back (`autoBlockRolledBackBundles`): ready() adds the id
8
+ * of a bundle that never got ready (LiveUpdate.java:460-462 of @capawesome/capacitor-live-update 8.7.1) to a comma
9
+ * separated list in its preferences (LiveUpdatePreferences.java:19, LiveUpdate.java:1510-1537). The stock code reads it
10
+ * only in sync(), which the guard refuses, so the guard reads it itself.
11
+ */
12
+ final class BlockedBundles {
13
+
14
+ /** The stock plugin's key, which its source marks "DO NOT CHANGE". */
15
+ static final String KEY = "blockedBundleIds";
16
+
17
+ // The ids the guard lets reach the stock code are bundle ids (BundlesFolder), so a list holding anything else is
18
+ // one the guard cannot read, as after a plugin upgrade that changed its format.
19
+ private static final Pattern ID = Pattern.compile("[A-Za-z0-9_-]{1,64}");
20
+
21
+ private BlockedBundles() {}
22
+
23
+ /** Whether `list`, the stock plugin's, blocks `bundleId`; any id when the list cannot be read, so it fails closed. */
24
+ static boolean blocks(String list, String bundleId) {
25
+ if (list == null || list.isEmpty()) return false;
26
+ String[] ids = list.split(",", -1);
27
+ for (String id : ids) if (!ID.matcher(id).matches()) return true;
28
+ return Arrays.asList(ids).contains(bundleId);
29
+ }
30
+ }
@@ -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,10 @@
1
1
  package net.protobase.mobile;
2
2
 
3
+ import android.app.Activity;
4
+ import android.content.res.AssetManager;
5
+ import android.content.SharedPreferences;
6
+ import android.util.Base64;
7
+ import com.getcapacitor.Bridge;
3
8
  import com.getcapacitor.JSObject;
4
9
  import com.getcapacitor.Logger;
5
10
  import com.getcapacitor.Plugin;
@@ -8,15 +13,28 @@ import com.getcapacitor.PluginConfig;
8
13
  import com.getcapacitor.PluginMethod;
9
14
  import com.getcapacitor.annotation.CapacitorPlugin;
10
15
  import io.capawesome.capacitorjs.plugins.liveupdate.LiveUpdatePlugin;
16
+ import java.io.File;
17
+ import java.io.FileNotFoundException;
18
+ import java.io.IOException;
19
+ import java.io.InputStream;
11
20
  import java.lang.reflect.Method;
12
21
  import java.net.URI;
13
22
  import java.net.URISyntaxException;
23
+ import java.security.GeneralSecurityException;
24
+ import java.security.KeyFactory;
25
+ import java.security.PublicKey;
26
+ import java.security.interfaces.RSAPublicKey;
27
+ import java.security.spec.X509EncodedKeySpec;
14
28
  import java.util.ArrayList;
15
29
  import java.util.Arrays;
30
+ import java.util.HashMap;
16
31
  import java.util.Iterator;
17
32
  import java.util.List;
33
+ import java.util.Map;
34
+ import java.util.UUID;
35
+ import java.util.concurrent.ExecutorService;
36
+ import java.util.concurrent.Executors;
18
37
  import java.util.concurrent.atomic.AtomicInteger;
19
- import org.json.JSONObject;
20
38
 
21
39
  /**
22
40
  * Takes the stock LiveUpdate plugin's (8.7.1) place, so page script cannot steer the live updater. The CLI lists this
@@ -28,14 +46,24 @@ import org.json.JSONObject;
28
46
  * upgrade that adds a method leaves no LiveUpdate at all rather than an open one.
29
47
  * - The methods the shell needs that take no input clear what page script passed before the stock code reads it.
30
48
  * - 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.
49
+ * or loopback http, and downloads it itself (ReleaseDownload), bound by the release's statement to the id, the pinned
50
+ * `publicKey`, this binary and its order; a caller's checksum and signature are dropped. A few per run of the app, so
51
+ * a loop cannot drain the data plan.
52
+ * - Both take a release only above the order of the binary's own files, `builtInOrder`, which the CLI computes as the
53
+ * server computes a release's, so a reinstalled app cannot be taken below its own files; and neither takes a bundle
54
+ * the stock code blocked after rolling it back (BlockedBundles), which it checks only in the refused sync().
55
+ * - setNextBundle checks the bundle on disk (BundleCheck), then takes it only above the running bundle's order and not
56
+ * below the highest the app downloaded; a refused bundle that is neither running nor next is deleted, as the stock
57
+ * code deletes an unused one (LiveUpdate.java:803-810). It is the only way to a downloaded bundle: reset, sync, a
58
+ * next bundle of null (the built-in one) and the rest are refused, reload only runs the next bundle and a rollback
59
+ * goes to the built-in one inside the stock code (LiveUpdate.java:1411).
33
60
  * - Ids are a single safe component naming a direct child of the bundles folder: the stock code joins them to its path
34
61
  * unchecked (LiveUpdate.java:665-667,1299-1302).
35
62
  * - Everything else is refused: deleting, resetting, the vendor's cloud, config, channel and ids.
36
63
  * - load() also refuses background updates, which sync with the vendor's cloud outside every method here
37
- * (LiveUpdate.java:420-433), and needs `autoDeleteBundles` and a `readyTimeout`, so ready() drops every bundle but the
38
- * current and next one and a bundle that never gets ready rolls back: page script cannot pin an older one.
64
+ * (LiveUpdate.java:420-433), and needs `autoDeleteBundles`, a `readyTimeout` and `autoBlockRolledBackBundles`, so
65
+ * ready() drops every bundle but the current and next one, and a bundle that never gets ready rolls back and is not
66
+ * taken again: page script cannot pin an older one.
39
67
  *
40
68
  * The config comes from capacitor.config.json, which ships in the binary outside the web part, beyond page script and
41
69
  * a live update. This source set compiles only when the app has the plugin's Gradle project, which the CLI's swap and
@@ -46,6 +74,18 @@ public class GuardedLiveUpdate extends LiveUpdatePlugin {
46
74
 
47
75
  private static final int MAX_DOWNLOADS = 3;
48
76
  private static final AtomicInteger downloads = new AtomicInteger();
77
+ // One download at a time, and one lock over the highest order downloaded and the choice of the next bundle.
78
+ private static final ExecutorService downloader = Executors.newSingleThreadExecutor();
79
+ private static final Object order = new Object();
80
+ // Beside the bundles folder, on the same file system, so a checked download moves into it whole.
81
+ private static final String DOWNLOADS = "_protobase_live_update_downloads";
82
+ private static final String ORDER_PREFS = "ProtobaseLiveUpdate";
83
+ private static final String HIGHEST_ORDER = "highestOrder";
84
+
85
+ private String releasesUrl;
86
+ private PublicKey publicKey;
87
+ private ReleaseBinary binary;
88
+ private long[] builtInOrder;
49
89
 
50
90
  @Override
51
91
  public void load() {
@@ -65,9 +105,45 @@ public class GuardedLiveUpdate extends LiveUpdatePlugin {
65
105
  if (config.getInt("readyTimeout", 0) <= 0) {
66
106
  refuseToLoad("plugins.LiveUpdate.readyTimeout must be above 0 in a Protobase app");
67
107
  }
108
+ if (!config.getBoolean("autoBlockRolledBackBundles", false)) {
109
+ refuseToLoad("plugins.LiveUpdate.autoBlockRolledBackBundles must be true in a Protobase app");
110
+ }
111
+ builtInOrder = ReleaseOrder.parseBuiltIn(config.getString("builtInOrder"));
112
+ if (builtInOrder == null) {
113
+ refuseToLoad("plugins.LiveUpdate needs the order of the binary's own files, builtInOrder, in a Protobase app: run protobase mobile again");
114
+ }
115
+ releasesUrl = config.getString("releasesUrl");
116
+ if (!ownSource(releasesUrl)) {
117
+ refuseToLoad("plugins.LiveUpdate.releasesUrl must be an https URL, or http to the device itself, in a Protobase app");
118
+ }
119
+ publicKey = rsaKey(config.getString("publicKey"));
120
+ String fingerprint = config.getString("fingerprint");
121
+ String shellVersion = config.getString("shellVersion");
122
+ if (fingerprint == null || !Hex.isHex(fingerprint, 16) || !ReleaseStatement.isVersion(shellVersion)) {
123
+ refuseToLoad("plugins.LiveUpdate needs the binary's fingerprint and shellVersion in a Protobase app: run protobase mobile again");
124
+ }
125
+ binary = new ReleaseBinary(getContext().getPackageName(), fingerprint, shellVersion);
126
+ // What a download the app did not finish left.
127
+ File downloads = new File(getContext().getFilesDir(), DOWNLOADS);
128
+ BundleRemoval.delete(downloads, downloads);
68
129
  super.load();
69
130
  }
70
131
 
132
+ // The key the releases are signed with: an RSA public key as base64 SPKI DER, with or without PEM's lines, as the
133
+ // stock code reads it (LiveUpdate.java:744-755).
134
+ private static PublicKey rsaKey(String value) {
135
+ if (value == null || value.trim().isEmpty()) refuseToLoad("plugins.LiveUpdate.publicKey must be set in a Protobase app");
136
+ String body = value.replace("-----BEGIN PUBLIC KEY-----", "").replace("-----END PUBLIC KEY-----", "").replaceAll("\\s", "");
137
+ try {
138
+ PublicKey key = KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(Base64.decode(body, Base64.DEFAULT)));
139
+ if (!(key instanceof RSAPublicKey)) refuseToLoad("plugins.LiveUpdate.publicKey must be an RSA public key in a Protobase app");
140
+ return key;
141
+ } catch (GeneralSecurityException | IllegalArgumentException e) {
142
+ refuseToLoad("plugins.LiveUpdate.publicKey must be an RSA public key in a Protobase app");
143
+ return null;
144
+ }
145
+ }
146
+
71
147
  // Capacitor then leaves the plugin unregistered, logging only that it failed to load (PluginHandle.java:98-109), so
72
148
  // the reason is logged here.
73
149
  private static void refuseToLoad(String reason) {
@@ -80,12 +156,6 @@ public class GuardedLiveUpdate extends LiveUpdatePlugin {
80
156
  @Override
81
157
  @PluginMethod
82
158
  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
159
  String bundleId = call.getString("bundleId");
90
160
  boolean allowed =
91
161
  "manifest".equals(call.getString("artifactType")) &&
@@ -95,33 +165,137 @@ public class GuardedLiveUpdate extends LiveUpdatePlugin {
95
165
  Refusal.rejectMethod(call, "LiveUpdate", "downloadBundle");
96
166
  return;
97
167
  }
168
+ if (blocked(bundleId)) {
169
+ call.reject("LiveUpdate.downloadBundle refused release " + bundleId + ": it was rolled back");
170
+ return;
171
+ }
98
172
  if (downloads.incrementAndGet() > MAX_DOWNLOADS) {
99
173
  call.reject("LiveUpdate.downloadBundle was called too often in this run of the app");
100
174
  return;
101
175
  }
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);
176
+ clear(call);
177
+ downloader.execute(() -> download(call, bundleId));
178
+ }
179
+
180
+ // Answers the call once what the download left is deleted, so its caller never sees it.
181
+ private void download(PluginCall call, String bundleId) {
182
+ File downloads = new File(getContext().getFilesDir(), DOWNLOADS);
183
+ File dir = new File(downloads, UUID.randomUUID().toString());
184
+ String refusal = null;
185
+ try {
186
+ downloadInto(dir, bundleId);
187
+ } catch (BundleRefused e) {
188
+ refusal = "LiveUpdate.downloadBundle refused release " + bundleId + ": " + e.getMessage();
189
+ } catch (IOException e) {
190
+ refusal = "LiveUpdate.downloadBundle could not download release " + bundleId;
191
+ } finally {
192
+ BundleRemoval.delete(downloads, dir);
193
+ }
194
+ if (refusal == null) call.resolve();
195
+ else call.reject(refusal);
196
+ }
197
+
198
+ // Downloads release `bundleId` into `dir`, then moves it into the bundles folder and raises the highest order.
199
+ private void downloadInto(File dir, String bundleId) throws BundleRefused, IOException {
200
+ File bundles = BundlesFolder.of(getContext());
201
+ if (!dir.mkdirs() || !(bundles.isDirectory() || bundles.mkdirs())) throw new IOException("Cannot create " + dir);
202
+ String current = bundleIdOf(getBridge().getServerBasePath());
203
+ OwnFiles own = current == null ? builtInFiles() : OwnFiles.inFolder(new File(bundles, current));
204
+ long[] highest;
205
+ synchronized (order) {
206
+ highest = highestOrder();
207
+ }
208
+ ReleaseStatement statement = new ReleaseDownload(releasesUrl, publicKey, binary).download(bundleId, dir, own, builtInOrder, highest);
209
+ File target = new File(bundles, bundleId);
210
+ // The same release downloaded before stays; setNextBundle checks it anyway.
211
+ if (!target.exists() && !dir.renameTo(target)) throw new IOException("Cannot move " + dir);
212
+ synchronized (order) {
213
+ if (ReleaseOrder.compare(statement.order, highestOrder()) > 0) {
214
+ prefs().edit().putString(HIGHEST_ORDER, ReleaseOrder.format(statement.order)).commit();
215
+ }
216
+ }
107
217
  }
108
218
 
109
219
  @Override
110
220
  @PluginMethod
111
221
  public void setNextBundle(PluginCall call) {
222
+ // A null id, which the stock code takes for the built-in bundle, is refused too: it would take the app below
223
+ // what it downloaded, and only the stock rollback goes there.
112
224
  Object bundleId = call.getData().opt("bundleId");
113
- if (bundleId == null || bundleId == JSONObject.NULL) {
114
- // The stock code resets to the built-in bundle.
115
- clear(call);
116
- super.setNextBundle(call);
117
- return;
118
- }
119
225
  if (!(bundleId instanceof String) || !BundlesFolder.isBundleId(BundlesFolder.of(getContext()), (String) bundleId)) {
120
226
  Refusal.rejectMethod(call, "LiveUpdate", "setNextBundle");
121
227
  return;
122
228
  }
123
- clear(call).put("bundleId", bundleId);
124
- super.setNextBundle(call);
229
+ String id = (String) bundleId;
230
+ File bundles = BundlesFolder.of(getContext());
231
+ synchronized (order) {
232
+ try {
233
+ if (blocked(id)) throw new BundleRefused("it was rolled back");
234
+ ReleaseStatement next = BundleCheck.check(new File(bundles, id), id, publicKey, binary);
235
+ ReleaseOrder.acceptNext(next.order, currentOrder(bundles), highestOrder());
236
+ } catch (BundleRefused e) {
237
+ if (!id.equals(bundleIdOf(getBridge().getServerBasePath())) && !id.equals(bundleIdOf(nextPath()))) {
238
+ BundleRemoval.delete(bundles, new File(bundles, id));
239
+ }
240
+ call.reject("LiveUpdate.setNextBundle refused bundle " + id + ": " + e.getMessage());
241
+ return;
242
+ }
243
+ clear(call).put("bundleId", id);
244
+ super.setNextBundle(call);
245
+ }
246
+ }
247
+
248
+ // The binary's own files, in the assets folder Capacitor serves them from, as the manifest the CLI writes beside them lists them; none without one, as in a binary built before it.
249
+ private OwnFiles builtInFiles() throws IOException {
250
+ AssetManager assets = getContext().getAssets();
251
+ ReleaseManifest manifest;
252
+ try (InputStream in = assets.open(Bridge.DEFAULT_WEB_ASSET_DIR + "/" + ReleaseManifest.FILE)) {
253
+ manifest = ReleaseManifest.parse(Bounded.read(in, ReleaseManifest.MAX_BYTES, "the built-in manifest"), new File(Bridge.DEFAULT_WEB_ASSET_DIR));
254
+ } catch (FileNotFoundException | BundleRefused e) {
255
+ return OwnFiles.NONE;
256
+ }
257
+ Map<String, String> hrefs = new HashMap<>();
258
+ for (ReleaseManifest.Item item : manifest.items) hrefs.put(item.checksum, item.href);
259
+ return checksum -> hrefs.containsKey(checksum) ? assets.open(Bridge.DEFAULT_WEB_ASSET_DIR + "/" + hrefs.get(checksum)) : null;
260
+ }
261
+
262
+ // The running bundle's order: the built-in one's, or what its statement says, checked against the key.
263
+ private long[] currentOrder(File bundles) throws BundleRefused {
264
+ String current = bundleIdOf(getBridge().getServerBasePath());
265
+ if (current == null) return builtInOrder;
266
+ try {
267
+ byte[] statement = Bounded.read(new File(new File(bundles, current), ReleaseStatement.FILE), ReleaseStatement.MAX_BYTES, "the running bundle's statement");
268
+ return ReleaseStatement.verify(statement, current, publicKey).order;
269
+ } catch (IOException e) {
270
+ throw new BundleRefused("the running bundle's statement could not be read");
271
+ }
272
+ }
273
+
274
+ private long[] highestOrder() {
275
+ return ReleaseOrder.parse(prefs().getString(HIGHEST_ORDER, null));
276
+ }
277
+
278
+ private boolean blocked(String bundleId) {
279
+ String list = getContext().getSharedPreferences(LiveUpdatePlugin.SHARED_PREFERENCES_NAME, Activity.MODE_PRIVATE).getString(BlockedBundles.KEY, null);
280
+ return BlockedBundles.blocks(list, bundleId);
281
+ }
282
+
283
+ private SharedPreferences prefs() {
284
+ return getContext().getSharedPreferences(ORDER_PREFS, Activity.MODE_PRIVATE);
285
+ }
286
+
287
+ // Where the stock code keeps the next bundle, Capacitor's server path (LiveUpdate.java:1550, Bridge.java:295-302).
288
+ private String nextPath() {
289
+ return getContext().getSharedPreferences(com.getcapacitor.plugin.WebView.WEBVIEW_PREFS_NAME, Activity.MODE_PRIVATE).getString(com.getcapacitor.plugin.WebView.CAP_SERVER_PATH, null);
290
+ }
291
+
292
+ // The id of the downloaded bundle at `path`, as the stock code names it (LiveUpdate.java:1214-1262), or null for the
293
+ // built-in one and any path outside the bundles folder.
294
+ private String bundleIdOf(String path) {
295
+ if (path == null || path.isEmpty()) return null;
296
+ File bundles = BundlesFolder.of(getContext());
297
+ String name = new File(path).getName();
298
+ return BundlesFolder.isBundleId(bundles, name) && new File(bundles, name).getAbsolutePath().equals(new File(path).getAbsolutePath()) ? name : null;
125
299
  }
126
300
 
127
301
  // 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,38 @@
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.util.HashMap;
8
+ import java.util.Map;
9
+
10
+ /**
11
+ * The files of the bundle the app runs, by checksum, which a download copies instead of fetching: a downloaded
12
+ * bundle's folder, or the binary's own files. A copy is hashed again as it is written, so it is bound by its content
13
+ * to the release's manifest, never by where it came from.
14
+ */
15
+ interface OwnFiles {
16
+
17
+ /** None: every file is fetched. */
18
+ OwnFiles NONE = checksum -> null;
19
+
20
+ /** The file of checksum `checksum`, opened, or null when the bundle has none. */
21
+ InputStream open(String checksum) throws IOException;
22
+
23
+ /** The files of the bundle folder `dir` as its own manifest lists them; none when it has none that parses. */
24
+ static OwnFiles inFolder(File dir) throws IOException {
25
+ ReleaseManifest manifest;
26
+ try {
27
+ manifest = ReleaseManifest.parse(Bounded.read(new File(dir, ReleaseManifest.FILE), ReleaseManifest.MAX_BYTES, "its manifest"), dir);
28
+ } catch (BundleRefused e) {
29
+ return NONE;
30
+ }
31
+ Map<String, File> files = new HashMap<>();
32
+ for (ReleaseManifest.Item item : manifest.items) {
33
+ File file = new File(dir, item.href);
34
+ if (file.isFile() && !BundleRemoval.isLink(file)) files.put(item.checksum, file);
35
+ }
36
+ return checksum -> files.containsKey(checksum) ? new FileInputStream(files.get(checksum)) : null;
37
+ }
38
+ }
@@ -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,108 @@
1
+ package net.protobase.mobile;
2
+
3
+ import java.io.File;
4
+ import java.io.FileOutputStream;
5
+ import java.io.IOException;
6
+ import java.io.InputStream;
7
+ import java.io.OutputStream;
8
+ import java.io.UnsupportedEncodingException;
9
+ import java.net.HttpURLConnection;
10
+ import java.net.URI;
11
+ import java.net.URLEncoder;
12
+ import java.security.PublicKey;
13
+
14
+ /**
15
+ * Downloads release `bundleId` from `releasesUrl/<id>?href=<file>` into a folder, in place of the stock downloader,
16
+ * which joins a manifest's hrefs to its path unchecked and checks each file only against its own signature
17
+ * (LiveUpdate.java:817-872 of @capawesome/capacitor-live-update 8.7.1). First the statement, checked against the id,
18
+ * the key, this binary and the highest order the app downloaded; then the manifest, checked against the statement and
19
+ * every href against the folder before any file is fetched; then each file, hashed as it is written and refused unless
20
+ * it is the one the manifest names. A file the running bundle has with the same checksum (OwnFiles) is copied from it
21
+ * and hashed again, so it is bound by its content, never by its href.
22
+ */
23
+ final class ReleaseDownload {
24
+
25
+ private static final int TIMEOUT_MS = 30_000;
26
+
27
+ private final String releasesUrl;
28
+ private final PublicKey key;
29
+ private final ReleaseBinary binary;
30
+
31
+ ReleaseDownload(String releasesUrl, PublicKey key, ReleaseBinary binary) {
32
+ this.releasesUrl = releasesUrl;
33
+ this.key = key;
34
+ this.binary = binary;
35
+ }
36
+
37
+ /**
38
+ * Fills the empty folder `dir` with release `bundleId`, copying what `own`, the running bundle's files, has, and
39
+ * checks it as a whole: its order above the built-in files' `builtIn` and not below `highest`, the highest
40
+ * the app downloaded. Returns its statement.
41
+ */
42
+ ReleaseStatement download(String bundleId, File dir, OwnFiles own, long[] builtIn, long[] highest) throws BundleRefused, IOException {
43
+ byte[] statementBytes = fetch(bundleId, ReleaseStatement.FILE, ReleaseStatement.MAX_BYTES);
44
+ ReleaseStatement statement = ReleaseStatement.verify(statementBytes, bundleId, key);
45
+ binary.accept(statement);
46
+ ReleaseOrder.acceptDownload(statement.order, builtIn, highest);
47
+ byte[] manifestBytes = fetch(bundleId, ReleaseManifest.FILE, ReleaseManifest.MAX_BYTES);
48
+ ReleaseManifest manifest = ReleaseManifest.verify(manifestBytes, statement, dir);
49
+ for (ReleaseManifest.Item item : manifest.items) {
50
+ File target = new File(dir, item.href);
51
+ File parent = target.getParentFile();
52
+ if (!parent.isDirectory() && !parent.mkdirs()) throw new IOException("Cannot create " + parent);
53
+ InputStream copy = own.open(item.checksum);
54
+ if (copy != null && item.checksum.equals(write(copy, target, item.size))) continue;
55
+ if (!item.checksum.equals(write(open(bundleId, item.href), target, item.size))) {
56
+ throw new BundleRefused("its " + item.href + " is not the file its manifest names");
57
+ }
58
+ }
59
+ writeBytes(new File(dir, ReleaseStatement.FILE), statementBytes);
60
+ writeBytes(new File(dir, ReleaseManifest.FILE), manifestBytes);
61
+ return BundleCheck.check(dir, bundleId, key, binary);
62
+ }
63
+
64
+ // Writes at most `size` bytes of `in` to `target`; returns their sha256, or null when there were more or fewer.
65
+ private static String write(InputStream in, File target, long size) throws IOException {
66
+ try (InputStream source = in; OutputStream out = new FileOutputStream(target)) {
67
+ String checksum = Sha256.copy(source, out, size);
68
+ return target.length() == size ? checksum : null;
69
+ }
70
+ }
71
+
72
+ private static void writeBytes(File target, byte[] bytes) throws IOException {
73
+ try (OutputStream out = new FileOutputStream(target)) {
74
+ out.write(bytes);
75
+ }
76
+ }
77
+
78
+ private byte[] fetch(String bundleId, String href, int limit) throws BundleRefused, IOException {
79
+ try (InputStream in = open(bundleId, href)) {
80
+ return Bounded.read(in, limit, "its " + href);
81
+ }
82
+ }
83
+
84
+ // The file `href` of the release, answered 200 by the releases URL itself: a redirect is no answer.
85
+ private InputStream open(String bundleId, String href) throws BundleRefused, IOException {
86
+ HttpURLConnection connection = (HttpURLConnection) URI.create(releasesUrl + "/" + bundleId + "?href=" + query(href)).toURL().openConnection();
87
+ connection.setInstanceFollowRedirects(false);
88
+ connection.setUseCaches(false);
89
+ connection.setConnectTimeout(TIMEOUT_MS);
90
+ connection.setReadTimeout(TIMEOUT_MS);
91
+ int status = connection.getResponseCode();
92
+ if (status != HttpURLConnection.HTTP_OK) {
93
+ connection.disconnect();
94
+ throw new BundleRefused("the server answered " + status + " for " + href);
95
+ }
96
+ return connection.getInputStream();
97
+ }
98
+
99
+ // The href as a form value: a space as %20, which every server decodes alike, rather than `+`.
100
+ private static String query(String href) {
101
+ try {
102
+ return URLEncoder.encode(href, "UTF-8").replace("+", "%20");
103
+ } catch (UnsupportedEncodingException e) {
104
+ // Every runtime has UTF-8.
105
+ throw new IllegalStateException(e);
106
+ }
107
+ }
108
+ }
@@ -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,76 @@
1
+ package net.protobase.mobile;
2
+
3
+ import java.util.regex.Pattern;
4
+
5
+ /**
6
+ * Release orders, compared element by element, a missing element counting as 0, so a later server can add one without
7
+ * breaking binaries already shipped. Kept as text such as `1800000000000,0`.
8
+ */
9
+ final class ReleaseOrder {
10
+
11
+ /** No order: below every release, the highest order downloaded before the app downloaded any. */
12
+ static final long[] NONE = { 0 };
13
+
14
+ // An order the CLI writes: up to 8 elements of up to 19 digits, which parse() still checks for overflow.
15
+ private static final Pattern TEXT = Pattern.compile("[0-9]{1,19}(,[0-9]{1,19}){0,7}");
16
+
17
+ private ReleaseOrder() {}
18
+
19
+ static int compare(long[] a, long[] b) {
20
+ for (int i = 0; i < Math.max(a.length, b.length); i++) {
21
+ long x = i < a.length ? a[i] : 0;
22
+ long y = i < b.length ? b[i] : 0;
23
+ if (x != y) return x < y ? -1 : 1;
24
+ }
25
+ return 0;
26
+ }
27
+
28
+ /**
29
+ * Refuses a bundle of order `next` as the next bundle unless it is above the running one's, `current`, and not below
30
+ * the highest order the app downloaded, `highest`.
31
+ */
32
+ static void acceptNext(long[] next, long[] current, long[] highest) throws BundleRefused {
33
+ if (compare(next, current) <= 0) throw new BundleRefused("it is not newer than the bundle the app runs");
34
+ if (compare(next, highest) < 0) throw new BundleRefused("it is older than a release the app downloaded");
35
+ }
36
+
37
+ /**
38
+ * Refuses to download a release of order `order` unless it is above the app's built-in files, `builtIn`, and not
39
+ * below the highest order the app downloaded, `highest`: equal to it is the same release again, which the app
40
+ * downloaded and may still have to set as the next bundle.
41
+ */
42
+ static void acceptDownload(long[] order, long[] builtIn, long[] highest) throws BundleRefused {
43
+ if (compare(order, builtIn) <= 0) throw new BundleRefused("it is not newer than the app's built-in files");
44
+ if (compare(order, highest) < 0) throw new BundleRefused("it is older than a release the app downloaded");
45
+ }
46
+
47
+ static String format(long[] order) {
48
+ StringBuilder text = new StringBuilder();
49
+ for (int i = 0; i < order.length; i++) text.append(i == 0 ? "" : ",").append(order[i]);
50
+ return text.toString();
51
+ }
52
+
53
+ /**
54
+ * The order the CLI wrote, `plugins.LiveUpdate.builtInOrder`, or null when it is missing, malformed or no order
55
+ * above none, so the guard never runs with a floor of 0.
56
+ */
57
+ static long[] parseBuiltIn(String text) {
58
+ if (text == null || !TEXT.matcher(text).matches()) return null;
59
+ long[] order;
60
+ try {
61
+ order = parse(text);
62
+ } catch (NumberFormatException e) {
63
+ return null;
64
+ }
65
+ return compare(order, NONE) > 0 ? order : null;
66
+ }
67
+
68
+ /** The order `text` holds, or none. Only this class writes it. */
69
+ static long[] parse(String text) {
70
+ if (text == null || text.isEmpty()) return NONE;
71
+ String[] parts = text.split(",");
72
+ long[] order = new long[parts.length];
73
+ for (int i = 0; i < parts.length; i++) order[i] = Long.parseLong(parts[i]);
74
+ return order;
75
+ }
76
+ }
@@ -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.4",
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": 7,
26
26
  "capacitor": {
27
27
  "ios": {
28
28
  "src": "ios"