@protobase/mobile 0.57.4 → 0.57.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/ios/Sources/ProtobaseMobile/BlockedBundles.swift +26 -0
  2. package/ios/Sources/ProtobaseMobile/BundleCheck.swift +47 -0
  3. package/ios/Sources/ProtobaseMobile/BundleRefused.swift +8 -0
  4. package/ios/Sources/ProtobaseMobile/BundleRemoval.swift +23 -0
  5. package/ios/Sources/ProtobaseMobile/GuardedLiveUpdatePlugin.swift +72 -17
  6. package/ios/Sources/ProtobaseMobile/Hex.swift +39 -0
  7. package/ios/Sources/ProtobaseMobile/Json.swift +19 -0
  8. package/ios/Sources/ProtobaseMobile/LiveUpdateDownload.swift +6 -10
  9. package/ios/Sources/ProtobaseMobile/LiveUpdateGuard.swift +121 -0
  10. package/ios/Sources/ProtobaseMobile/LiveUpdateLoadCheck.swift +2 -1
  11. package/ios/Sources/ProtobaseMobile/LiveUpdateMarks.swift +23 -0
  12. package/ios/Sources/ProtobaseMobile/LiveUpdateMethods.swift +4 -2
  13. package/ios/Sources/ProtobaseMobile/LiveUpdatePins.swift +36 -0
  14. package/ios/Sources/ProtobaseMobile/LiveUpdateSettings.swift +12 -3
  15. package/ios/Sources/ProtobaseMobile/LocalFile.swift +87 -0
  16. package/ios/Sources/ProtobaseMobile/OwnFiles.swift +29 -0
  17. package/ios/Sources/ProtobaseMobile/ReleaseBinary.swift +21 -0
  18. package/ios/Sources/ProtobaseMobile/ReleaseDownload.swift +55 -0
  19. package/ios/Sources/ProtobaseMobile/ReleaseFetch.swift +100 -0
  20. package/ios/Sources/ProtobaseMobile/ReleaseKey.swift +79 -0
  21. package/ios/Sources/ProtobaseMobile/ReleaseManifest.swift +72 -0
  22. package/ios/Sources/ProtobaseMobile/ReleaseOrder.swift +63 -0
  23. package/ios/Sources/ProtobaseMobile/ReleaseStatement.swift +60 -0
  24. package/ios/Sources/ProtobaseMobile/Sha256.swift +31 -0
  25. package/package.json +2 -2
@@ -0,0 +1,26 @@
1
+ import Foundation
2
+
3
+ /**
4
+ * The bundles the stock plugin blocked after rolling them back (`autoBlockRolledBackBundles`): ready() adds the id of
5
+ * a bundle that never got ready to a comma separated list in its user defaults (LiveUpdate.swift, addBlockedBundleId,
6
+ * and LiveUpdatePreferences.swift of @capawesome/capacitor-live-update 8.7.1). The stock code reads it only in sync(),
7
+ * which the guard refuses, so the guard reads it itself.
8
+ */
9
+ enum BlockedBundles {
10
+ /** The stock plugin's key: its prefix and key, which its source marks "DO NOT CHANGE". */
11
+ static let key = "CapawesomeLiveUpdate.blockedBundleIds"
12
+
13
+ /**
14
+ * Whether `value`, the stock list as stored, blocks `bundleId`. The stock code stores nothing until it blocks one,
15
+ * so none is an empty list; a value of another type, or an entry that is no bundle id, as after a plugin upgrade
16
+ * that changed its format, is a list the guard cannot read, which blocks every id, so it fails closed.
17
+ */
18
+ static func blocks(_ value: Any?, _ bundleId: String) -> Bool {
19
+ guard let value else { return false }
20
+ guard let list = value as? String else { return true }
21
+ if list.isEmpty { return false }
22
+ let ids = list.split(separator: ",", omittingEmptySubsequences: false).map(String.init)
23
+ if !ids.allSatisfy(BundlesFolder.isBundleIdShape) { return true }
24
+ return ids.contains(bundleId)
25
+ }
26
+ }
@@ -0,0 +1,47 @@
1
+ import Foundation
2
+
3
+ /**
4
+ * Checks a bundle folder on disk before the app runs it: its statement names its id and is signed with the app's key
5
+ * for this binary, its manifest is the one the statement names, every file it lists is there with its checksum and
6
+ * size, and the folder holds nothing else, no link either. Nothing is followed through a link.
7
+ */
8
+ enum BundleCheck {
9
+ static func check(_ dir: String, bundleId: String, key: ReleaseKey, binary: ReleaseBinary) throws -> ReleaseStatement {
10
+ if LocalFile.kind(dir) != .folder { throw BundleRefused("it is no bundle folder") }
11
+ let statement = try ReleaseStatement.verify(LocalFile.read(dir + "/" + ReleaseStatement.file, limit: ReleaseStatement.maxBytes, "its statement"), bundleId: bundleId, key: key)
12
+ try binary.accept(statement)
13
+ let manifest = try ReleaseManifest.verify(LocalFile.read(dir + "/" + ReleaseManifest.file, limit: ReleaseManifest.maxBytes, "its manifest"), statement: statement)
14
+ var expected: Set<String> = [ReleaseStatement.file, ReleaseManifest.file]
15
+ for item in manifest.items { expected.insert(item.href) }
16
+ var budget = expected.count * 2
17
+ try walk(dir, prefix: "", expected: expected, budget: &budget)
18
+ for item in manifest.items {
19
+ let path = dir + "/" + item.href
20
+ if LocalFile.kind(path) != .file { throw BundleRefused("it lacks \(item.href)") }
21
+ if try Sha256.of(file: path) != item.checksum || fileSize(path) != item.size { throw BundleRefused("its \(item.href) is not the file its manifest names") }
22
+ }
23
+ return statement
24
+ }
25
+
26
+ // Every entry below `dir`, as `prefix` + its name: a file must be expected by its exact name, a folder is walked, a
27
+ // link or anything else refuses the bundle. `budget` bounds the entries, so a planted tree cannot keep the check busy.
28
+ private static func walk(_ dir: String, prefix: String, expected: Set<String>, budget: inout Int) throws {
29
+ guard let names = try? FileManager.default.contentsOfDirectory(atPath: dir) else { throw BundleRefused("it could not be read") }
30
+ for name in names {
31
+ budget -= 1
32
+ if budget < 0 { throw BundleRefused("its folder holds files its manifest does not list") }
33
+ let path = prefix + name
34
+ switch LocalFile.kind(dir + "/" + name) {
35
+ case .folder: try walk(dir + "/" + name, prefix: path + "/", expected: expected, budget: &budget)
36
+ case .file: if !expected.contains(path) { throw BundleRefused("its folder holds \(path), which its manifest does not list") }
37
+ case .link: throw BundleRefused("its folder holds a link")
38
+ case .other, .missing: throw BundleRefused("its folder holds \(path), which is no file")
39
+ }
40
+ }
41
+ }
42
+
43
+ private static func fileSize(_ path: String) -> Int64 {
44
+ var info = stat()
45
+ return lstat(path, &info) == 0 ? Int64(info.st_size) : -1
46
+ }
47
+ }
@@ -0,0 +1,8 @@
1
+ /** Why the guard refuses a release or a bundle, said as the end of "refused release <id>: …". */
2
+ struct BundleRefused: Error, Equatable {
3
+ let reason: String
4
+
5
+ init(_ reason: String) {
6
+ self.reason = reason
7
+ }
8
+ }
@@ -0,0 +1,23 @@
1
+ import Foundation
2
+
3
+ /** Deletes a folder the guard owns, never following a link out of it: a link is deleted itself. */
4
+ enum BundleRemoval {
5
+ /** Deletes `target` when it is a direct child of `root`. */
6
+ static func delete(root: String, target: String) {
7
+ let parent = URL(fileURLWithPath: target).standardized.deletingLastPathComponent().path
8
+ guard parent == URL(fileURLWithPath: root).standardized.path else { return }
9
+ deleteTree(target)
10
+ }
11
+
12
+ private static func deleteTree(_ path: String) {
13
+ switch LocalFile.kind(path) {
14
+ case .missing:
15
+ return
16
+ case .folder:
17
+ for name in (try? FileManager.default.contentsOfDirectory(atPath: path)) ?? [] { deleteTree(path + "/" + name) }
18
+ rmdir(path)
19
+ case .file, .link, .other:
20
+ unlink(path)
21
+ }
22
+ }
23
+ }
@@ -8,11 +8,16 @@ import Capacitor
8
8
  * script reaches; page script's input never reaches the stock code, which gets a new call with what the guard built.
9
9
  *
10
10
  * - The methods the shell needs that take no input get none.
11
- * - downloadBundle: see LiveUpdateDownload and DownloadBudget.
12
- * - setNextBundle takes null, which resets to the built-in bundle, or an id naming a bundle (BundlesFolder.isBundleId):
13
- * the stock code joins ids to its path unchecked (LiveUpdate.swift, buildBundleURLFor).
14
- * - Everything else is refused, and so is every method when load() refuses to load (LiveUpdateLoadCheck): the stock
15
- * plugin then never loads.
11
+ * - downloadBundle takes only a manifest from the app's own releases URL, `releasesUrl + "/" + bundleId`, and the guard
12
+ * downloads it itself (LiveUpdateGuard, ReleaseDownload), bound by the release's statement to the id, the pinned
13
+ * `publicKey`, this binary and its order. A few per run of the app (DownloadBudget).
14
+ * - Both take a release only above the order of the binary's own files, `builtInOrder`, and neither takes a bundle the
15
+ * stock code blocked after rolling it back (BlockedBundles), which it checks only in the refused sync().
16
+ * - setNextBundle takes an id naming a bundle (BundlesFolder.isBundleId), checks it on disk (BundleCheck) and takes it
17
+ * only above the running bundle's order and not below the highest the app downloaded. It is the only way to a
18
+ * downloaded bundle: null, which the stock code takes for the built-in files, reset, sync and the rest are refused.
19
+ * - Everything else is refused, and so is every method when load() refuses to load (LiveUpdateLoadCheck,
20
+ * LiveUpdatePins): the stock plugin then never loads.
16
21
  */
17
22
  @objc(GuardedLiveUpdatePlugin)
18
23
  public class GuardedLiveUpdatePlugin: CAPPlugin, CAPBridgedPlugin {
@@ -20,24 +25,59 @@ public class GuardedLiveUpdatePlugin: CAPPlugin, CAPBridgedPlugin {
20
25
  public let jsName = "LiveUpdate"
21
26
  public let pluginMethods = LiveUpdateMethods.declared.map { CAPPluginMethod(name: $0, returnType: CAPPluginReturnPromise)! }
22
27
 
28
+ // One download at a time.
29
+ private static let downloads = DispatchQueue(label: "net.protobase.mobile.live-update-downloads")
30
+
23
31
  private var stock: StockLiveUpdate?
24
- private var settings: LiveUpdateSettings?
32
+ private var guarded: LiveUpdateGuard?
25
33
 
26
34
  override public func load() {
27
- start(LiveUpdateSettings(getConfig()), StockLiveUpdate.make())
35
+ start(LiveUpdateSettings(getConfig()), StockLiveUpdate.make(), appId: Bundle.main.bundleIdentifier, environment: appEnvironment)
28
36
  }
29
37
 
30
- func start(_ settings: LiveUpdateSettings, _ stock: StockLiveUpdate?) {
38
+ func start(_ settings: LiveUpdateSettings, _ stock: StockLiveUpdate?, appId: String?, environment: (LiveUpdatePins) -> LiveUpdateGuard.Environment) {
31
39
  if let reason = LiveUpdateLoadCheck.refusal(settings, stockMethods: stock?.methodNames, stockResponds: { stock?.responds(to: $0) ?? false }) {
32
40
  CAPLog.print("⚡️ ", reason)
33
41
  return
34
42
  }
43
+ let pins: LiveUpdatePins
44
+ switch LiveUpdatePins.make(settings, appId: appId) {
45
+ case .failure(let refusal):
46
+ CAPLog.print("⚡️ ", refusal.reason)
47
+ return
48
+ case .success(let made):
49
+ pins = made
50
+ }
35
51
  guard let stock else { return }
52
+ let guarded = LiveUpdateGuard(pins: pins, environment: environment(pins))
53
+ guarded.clearDownloads()
36
54
  stock.load(guardedBy: self)
37
- self.settings = settings
55
+ self.guarded = guarded
38
56
  self.stock = stock
39
57
  }
40
58
 
59
+ // Where the stock code keeps bundles and the next one (LiveUpdate.swift, bundlesDirectory and defaultServerPathKey)
60
+ // and Capacitor its built-in files (CAPBridgeViewController.swift, the `public` folder of the app's bundle).
61
+ private func appEnvironment(_ pins: LiveUpdatePins) -> LiveUpdateGuard.Environment {
62
+ LiveUpdateGuard.Environment(
63
+ bundles: BundlesFolder.path,
64
+ downloads: NSHomeDirectory() + "/Library/NoCloud/protobase_live_update_downloads",
65
+ builtInFiles: Bundle.main.bundlePath + "/public",
66
+ defaults: .standard,
67
+ currentPath: { [weak self] in self?.serverBasePath() },
68
+ nextPath: { KeyValueStore.standard["serverBasePath", as: String.self] },
69
+ source: ReleaseFetch(releasesUrl: pins.releasesUrl))
70
+ }
71
+
72
+ private func serverBasePath() -> String? {
73
+ if Thread.isMainThread { return MainActor.assumeIsolated { readServerBasePath() } }
74
+ return DispatchQueue.main.sync { MainActor.assumeIsolated { readServerBasePath() } }
75
+ }
76
+
77
+ @MainActor private func readServerBasePath() -> String? {
78
+ (bridge?.viewController as? CAPBridgeViewController)?.getServerBasePath()
79
+ }
80
+
41
81
  private func refuse(_ call: CAPPluginCall) {
42
82
  Refusal.reject(call, plugin: jsName)
43
83
  }
@@ -47,20 +87,35 @@ public class GuardedLiveUpdatePlugin: CAPPlugin, CAPBridgedPlugin {
47
87
  stock.forward(call, call.methodName, options: [:])
48
88
  }
49
89
 
50
- // Checked
90
+ // Guarded
51
91
 
52
92
  @objc func downloadBundle(_ call: CAPPluginCall) {
53
- guard let stock, let settings, let options = LiveUpdateDownload.options(call.options, settings) else { return refuse(call) }
93
+ guard stock != nil, let guarded, let bundleId = LiveUpdateDownload.bundleId(call.options, releasesUrl: guarded.pins.releasesUrl) else { return refuse(call) }
94
+ if guarded.isBlocked(bundleId) { return call.reject("LiveUpdate.downloadBundle refused release \(bundleId): it was rolled back") }
54
95
  guard DownloadBudget.shared.take() else { return call.reject("LiveUpdate.downloadBundle was called too often in this run of the app") }
55
- stock.forward(call, "downloadBundle", options: options)
96
+ Self.downloads.async {
97
+ do {
98
+ try guarded.download(bundleId)
99
+ call.resolve()
100
+ } catch let refused as BundleRefused {
101
+ call.reject("LiveUpdate.downloadBundle refused release \(bundleId): \(refused.reason)")
102
+ } catch {
103
+ call.reject("LiveUpdate.downloadBundle could not download release \(bundleId)")
104
+ }
105
+ }
56
106
  }
57
107
 
58
108
  @objc func setNextBundle(_ call: CAPPluginCall) {
59
- guard let stock else { return refuse(call) }
60
- let bundleId = call.options["bundleId"]
61
- if bundleId == nil || bundleId is NSNull { return stock.forward(call, "setNextBundle", options: [:]) }
62
- guard let bundleId = bundleId as? String, BundlesFolder.isBundleId(bundleId) else { return refuse(call) }
63
- stock.forward(call, "setNextBundle", options: ["bundleId": bundleId])
109
+ guard let stock, let guarded, let bundleId = call.options["bundleId"] as? String, BundlesFolder.isBundleId(bundleId, in: guarded.environment.bundles) else {
110
+ return refuse(call)
111
+ }
112
+ do {
113
+ try guarded.setNext(bundleId) { stock.forward(call, "setNextBundle", options: ["bundleId": bundleId]) }
114
+ } catch let refused as BundleRefused {
115
+ call.reject("LiveUpdate.setNextBundle refused bundle \(bundleId): \(refused.reason)")
116
+ } catch {
117
+ call.reject("LiveUpdate.setNextBundle could not check bundle \(bundleId)")
118
+ }
64
119
  }
65
120
 
66
121
  // Cleared
@@ -0,0 +1,39 @@
1
+ import Foundation
2
+
3
+ /** Lowercase hex, as the server writes hashes and the statement; anything else is refused. */
4
+ enum Hex {
5
+ private static let digits = Array("0123456789abcdef".utf8)
6
+
7
+ static func encode<Bytes: Sequence>(_ bytes: Bytes) -> String where Bytes.Element == UInt8 {
8
+ var text: [UInt8] = []
9
+ for byte in bytes {
10
+ text.append(digits[Int(byte >> 4)])
11
+ text.append(digits[Int(byte & 0xf)])
12
+ }
13
+ return String(decoding: text, as: UTF8.self)
14
+ }
15
+
16
+ static func decode(_ text: String, _ what: String) throws -> Data {
17
+ let chars = Array(text.utf8)
18
+ guard chars.count % 2 == 0 else { throw BundleRefused("\(what) is no hex") }
19
+ var bytes = Data(capacity: chars.count / 2)
20
+ for i in stride(from: 0, to: chars.count, by: 2) {
21
+ guard let high = digit(chars[i]), let low = digit(chars[i + 1]) else { throw BundleRefused("\(what) is no hex") }
22
+ bytes.append(high << 4 | low)
23
+ }
24
+ return bytes
25
+ }
26
+
27
+ static func isHex(_ text: String, count: Int) -> Bool {
28
+ let chars = Array(text.utf8)
29
+ return chars.count == count && chars.allSatisfy { digit($0) != nil }
30
+ }
31
+
32
+ private static func digit(_ c: UInt8) -> UInt8? {
33
+ switch c {
34
+ case UInt8(ascii: "0")...UInt8(ascii: "9"): return c - UInt8(ascii: "0")
35
+ case UInt8(ascii: "a")...UInt8(ascii: "f"): return c - UInt8(ascii: "a") + 10
36
+ default: return nil
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,19 @@
1
+ import Foundation
2
+
3
+ /** What JSONSerialization gives, read strictly: an integer is neither a bool nor a fraction. */
4
+ enum Json {
5
+ static func parse(_ data: Data) -> Any? {
6
+ try? JSONSerialization.jsonObject(with: data, options: [])
7
+ }
8
+
9
+ static func integer(_ value: Any?) -> Int64? {
10
+ guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil }
11
+ switch String(cString: number.objCType) {
12
+ case "c", "s", "i", "l", "q", "C", "S", "I", "L", "Q":
13
+ // An unsigned value above Int64's range reads as negative, which every caller refuses.
14
+ return number.int64Value
15
+ default:
16
+ return nil
17
+ }
18
+ }
19
+ }
@@ -1,19 +1,15 @@
1
1
  import Foundation
2
2
 
3
3
  /**
4
- * What page script may download: a manifest from the app's own releases URL, `releasesUrl + "/" + bundleId`, with
5
- * `publicKey` pinned, so the stock code checks the manifest and each file against it with the response's signature
6
- * (LiveUpdate.swift, downloadAndVerifyFile and verifyFile). A caller's checksum and signature are dropped.
4
+ * What page script may ask to download: a manifest from the app's own releases URL, `releasesUrl + "/" + bundleId`,
5
+ * by a bundle id. The guard downloads it itself (ReleaseDownload); a caller's checksum, signature and key are ignored.
7
6
  */
8
7
  enum LiveUpdateDownload {
9
- /** The options the stock code downloads with, or nil when the call is refused. */
10
- static func options(_ call: [AnyHashable: Any], _ settings: LiveUpdateSettings) -> [String: Any]? {
11
- guard let releasesUrl = settings.releasesUrl, ReleasesUrl.isOwnSource(releasesUrl) else { return nil }
12
- guard let publicKey = settings.publicKey, !publicKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
8
+ /** The bundle id the call asks for, or nil when it is refused. */
9
+ static func bundleId(_ call: [AnyHashable: Any], releasesUrl: String) -> String? {
13
10
  guard call["artifactType"] as? String == "manifest", let bundleId = call["bundleId"] as? String else { return nil }
14
- let url = releasesUrl + "/" + bundleId
15
- guard BundlesFolder.isBundleIdShape(bundleId), call["url"] as? String == url else { return nil }
16
- return ["artifactType": "manifest", "bundleId": bundleId, "url": url]
11
+ guard BundlesFolder.isBundleIdShape(bundleId), call["url"] as? String == releasesUrl + "/" + bundleId else { return nil }
12
+ return bundleId
17
13
  }
18
14
  }
19
15
 
@@ -0,0 +1,121 @@
1
+ import Foundation
2
+
3
+ /**
4
+ * The guard's decisions on releases, apart from Capacitor so they can be tested: downloading one itself and choosing
5
+ * the next bundle. One lock covers the highest order downloaded and the choice of the next bundle, for every guard of
6
+ * the process.
7
+ */
8
+ final class LiveUpdateGuard {
9
+ /** What the guard reads of the app and the stock plugin, injected so tests stand in for them. */
10
+ struct Environment {
11
+ /** The bundles folder, where the stock code keeps bundles by id. */
12
+ var bundles: String
13
+ /** Where downloads are put together, beside the bundles folder on the same volume and outside it. */
14
+ var downloads: String
15
+ /** The binary's own files, with the manifest the CLI writes beside them. */
16
+ var builtInFiles: String
17
+ var defaults: UserDefaults
18
+ /** The path the app runs, Capacitor's server base path, or nil. */
19
+ var currentPath: () -> String?
20
+ /** The path the app runs at the next start, as the stock code keeps it, or nil for the built-in files. */
21
+ var nextPath: () -> String?
22
+ var source: ReleaseSource
23
+ }
24
+
25
+ private static let lock = NSLock()
26
+
27
+ let pins: LiveUpdatePins
28
+ let environment: Environment
29
+ private var marks: LiveUpdateMarks { LiveUpdateMarks(defaults: environment.defaults) }
30
+
31
+ init(pins: LiveUpdatePins, environment: Environment) {
32
+ self.pins = pins
33
+ self.environment = environment
34
+ }
35
+
36
+ /** Deletes what a download the app did not finish left. */
37
+ func clearDownloads() {
38
+ BundleRemoval.delete(root: (environment.downloads as NSString).deletingLastPathComponent, target: environment.downloads)
39
+ }
40
+
41
+ /** Whether the stock plugin blocked bundle `bundleId` after rolling it back. */
42
+ func isBlocked(_ bundleId: String) -> Bool {
43
+ BlockedBundles.blocks(environment.defaults.object(forKey: BlockedBundles.key), bundleId)
44
+ }
45
+
46
+ private func refuseBlocked(_ bundleId: String) throws {
47
+ if isBlocked(bundleId) { throw BundleRefused("it was rolled back") }
48
+ }
49
+
50
+ /** Downloads release `bundleId`, a bundle id, into the bundles folder, and raises the highest order downloaded. */
51
+ func download(_ bundleId: String) throws {
52
+ try refuseBlocked(bundleId)
53
+ let dir = environment.downloads + "/" + UUID().uuidString
54
+ defer { BundleRemoval.delete(root: environment.downloads, target: dir) }
55
+ try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
56
+ try FileManager.default.createDirectory(atPath: environment.bundles, withIntermediateDirectories: true)
57
+ let current = bundleIdOf(environment.currentPath())
58
+ let own = OwnFiles.inFolder(current.map { environment.bundles + "/" + $0 } ?? environment.builtInFiles)
59
+ let highest = try locked { try marks.highest() }
60
+ let release = ReleaseDownload(source: environment.source, key: pins.key, binary: pins.binary)
61
+ let statement = try release.download(bundleId, into: dir, own: own, builtIn: pins.builtInOrder, highest: highest)
62
+ // The same release downloaded before stays; setNextBundle checks it anyway.
63
+ let target = environment.bundles + "/" + bundleId
64
+ if LocalFile.kind(target) == .missing && rename(dir, target) != 0 { throw BundleRefused("it could not be moved into place") }
65
+ try locked { try marks.raise(statement.order) }
66
+ }
67
+
68
+ /**
69
+ * Checks bundle `bundleId`, which names a bundle folder, on disk, then has `choose` set it as the next bundle, under
70
+ * the lock: it must not be blocked, it must be above the running bundle's order and the built-in files', and not
71
+ * below the highest order downloaded. A refused bundle that is neither running nor next is deleted.
72
+ */
73
+ func setNext(_ bundleId: String, choose: () -> Void) throws {
74
+ try locked {
75
+ do {
76
+ try refuseBlocked(bundleId)
77
+ let next = try BundleCheck.check(environment.bundles + "/" + bundleId, bundleId: bundleId, key: pins.key, binary: pins.binary)
78
+ try ReleaseOrder.acceptNext(next.order, current: currentOrder(), builtIn: pins.builtInOrder, highest: marks.highest())
79
+ } catch let refused as BundleRefused {
80
+ if bundleId != self.bundleIdOf(environment.currentPath()) && bundleId != self.bundleIdOf(environment.nextPath()) {
81
+ BundleRemoval.delete(root: environment.bundles, target: environment.bundles + "/" + bundleId)
82
+ }
83
+ throw refused
84
+ }
85
+ choose()
86
+ }
87
+ }
88
+
89
+ // The running bundle's order: the built-in files', or what its statement says, checked against the key and this
90
+ // binary. One that cannot be read or checked refuses, rather than falling back to the built-in order.
91
+ private func currentOrder() throws -> [Int64] {
92
+ guard let current = bundleIdOf(environment.currentPath()) else { return pins.builtInOrder }
93
+ do {
94
+ let file = try LocalFile.read(environment.bundles + "/" + current + "/" + ReleaseStatement.file, limit: ReleaseStatement.maxBytes, "its statement")
95
+ let statement = try ReleaseStatement.verify(file, bundleId: current, key: pins.key)
96
+ try pins.binary.accept(statement)
97
+ return statement.order
98
+ } catch let refused as BundleRefused {
99
+ throw BundleRefused("the running bundle's statement could not be checked: \(refused.reason)")
100
+ }
101
+ }
102
+
103
+ /**
104
+ * The id of the downloaded bundle at `path`, as the stock code names it (LiveUpdate.swift, getCurrentBundleId), or
105
+ * nil for the built-in files and any path that is not a bundle folder's.
106
+ */
107
+ func bundleIdOf(_ path: String?) -> String? {
108
+ guard let path, !path.isEmpty else { return nil }
109
+ let url = URL(fileURLWithPath: path).standardized
110
+ let name = url.lastPathComponent
111
+ guard url.deletingLastPathComponent().path == URL(fileURLWithPath: environment.bundles).standardized.path,
112
+ BundlesFolder.isBundleId(name, in: environment.bundles) else { return nil }
113
+ return name
114
+ }
115
+
116
+ private func locked<T>(_ body: () throws -> T) rethrows -> T {
117
+ Self.lock.lock()
118
+ defer { Self.lock.unlock() }
119
+ return try body()
120
+ }
121
+ }
@@ -2,7 +2,8 @@
2
2
  * Why the guard refuses to load the live updater, or nil. A stock method the guard does not declare means a plugin
3
3
  * upgrade added one: no live updater then rather than an open one. Background updates sync with the vendor's cloud
4
4
  * outside every method (LiveUpdate.swift, handleLoad); `autoDeleteBundles` and a `readyTimeout` make ready() drop every
5
- * bundle but the current and next one and roll back a bundle that never gets ready, so page script cannot pin an older one.
5
+ * bundle but the current and next one and roll back a bundle that never gets ready, so page script cannot pin an
6
+ * older one. What the guard takes a release by is checked as it is read (LiveUpdatePins).
6
7
  */
7
8
  enum LiveUpdateLoadCheck {
8
9
  static func refusal(_ settings: LiveUpdateSettings, stockMethods: [String]?, stockResponds: (String) -> Bool) -> String? {
@@ -0,0 +1,23 @@
1
+ import Foundation
2
+
3
+ /**
4
+ * The highest order the app downloaded, kept in its user defaults, so a downgrade stays refused after a restart. Read
5
+ * and raised only under the guard's lock. A value the guard cannot read refuses every release rather than counting as
6
+ * none, which would drop the floor.
7
+ */
8
+ struct LiveUpdateMarks {
9
+ static let key = "ProtobaseLiveUpdate.highestOrder"
10
+
11
+ let defaults: UserDefaults
12
+
13
+ func highest() throws -> [Int64] {
14
+ guard let value = defaults.object(forKey: Self.key) else { return ReleaseOrder.none }
15
+ guard let text = value as? String, let order = ReleaseOrder.parse(text) else { throw BundleRefused("the highest order the app downloaded cannot be read") }
16
+ return order
17
+ }
18
+
19
+ /** Raises the mark to `order` when it is higher. */
20
+ func raise(_ order: [Int64]) throws {
21
+ if ReleaseOrder.compare(order, try highest()) > 0 { defaults.set(ReleaseOrder.format(order), forKey: Self.key) }
22
+ }
23
+ }
@@ -3,7 +3,9 @@ enum LiveUpdateMethods {
3
3
  /** Forwarded with their input cleared. */
4
4
  static let cleared = ["ready", "reload", "getCurrentBundle", "getNextBundle", "getDownloadedBundles", "getVersionCode", "getVersionName"]
5
5
  /** Forwarded with only the input the guard checked and built. */
6
- static let checked = ["downloadBundle", "setNextBundle"]
6
+ static let checked = ["setNextBundle"]
7
+ /** Done by the guard itself, never by the stock code. */
8
+ static let own = ["downloadBundle"]
7
9
  /** Never forwarded: deleting, resetting, the vendor's cloud, config, channel and ids. */
8
10
  static let refused = [
9
11
  "clearBlockedBundles", "deleteBundle", "fetchChannels", "fetchLatestBundle", "getBlockedBundles", "getBundles", "getChannel",
@@ -11,5 +13,5 @@ enum LiveUpdateMethods {
11
13
  ]
12
14
 
13
15
  static var forwarded: [String] { cleared + checked }
14
- static var declared: [String] { forwarded + refused }
16
+ static var declared: [String] { forwarded + own + refused }
15
17
  }
@@ -0,0 +1,36 @@
1
+ import Foundation
2
+
3
+ /** What the guard takes a release by, read from `plugins.LiveUpdate` once its load check passed. */
4
+ struct LiveUpdatePins {
5
+ let releasesUrl: String
6
+ let key: ReleaseKey
7
+ let binary: ReleaseBinary
8
+ let builtInOrder: [Int64]
9
+
10
+ /** The pins, or why the guard refuses to load without them. */
11
+ static func make(_ settings: LiveUpdateSettings, appId: String?) -> Result<LiveUpdatePins, LoadRefusal> {
12
+ if !settings.autoBlockRolledBackBundles { return .failure(LoadRefusal("plugins.LiveUpdate.autoBlockRolledBackBundles must be true in a Protobase app")) }
13
+ guard let builtInOrder = ReleaseOrder.parseBuiltIn(settings.builtInOrder) else {
14
+ return .failure(LoadRefusal("plugins.LiveUpdate needs the order of the binary's own files, builtInOrder, in a Protobase app: run protobase mobile again"))
15
+ }
16
+ guard let releasesUrl = settings.releasesUrl, ReleasesUrl.isOwnSource(releasesUrl) else {
17
+ return .failure(LoadRefusal("plugins.LiveUpdate.releasesUrl must be an https URL, or http to the device itself, in a Protobase app"))
18
+ }
19
+ guard let key = ReleaseKey.parse(settings.publicKey) else { return .failure(LoadRefusal("plugins.LiveUpdate.publicKey must be an RSA public key in a Protobase app")) }
20
+ guard let fingerprint = settings.fingerprint, Hex.isHex(fingerprint, count: 16), let shellVersion = settings.shellVersion, ReleaseStatement.isVersion(shellVersion) else {
21
+ return .failure(LoadRefusal("plugins.LiveUpdate needs the binary's fingerprint and shellVersion in a Protobase app: run protobase mobile again"))
22
+ }
23
+ guard let appId, !appId.isEmpty else { return .failure(LoadRefusal("LiveUpdate needs the app's bundle identifier")) }
24
+ let binary = ReleaseBinary(appId: appId, fingerprint: fingerprint, shellVersion: shellVersion)
25
+ return .success(LiveUpdatePins(releasesUrl: releasesUrl, key: key, binary: binary, builtInOrder: builtInOrder))
26
+ }
27
+ }
28
+
29
+ /** Why the guard refuses to load the live updater. */
30
+ struct LoadRefusal: Error {
31
+ let reason: String
32
+
33
+ init(_ reason: String) {
34
+ self.reason = reason
35
+ }
36
+ }
@@ -2,15 +2,20 @@ import Capacitor
2
2
 
3
3
  /**
4
4
  * The live updater's `plugins.LiveUpdate` settings the guard checks, read with the stock plugin's own keys and defaults
5
- * (capacitor-live-update's LiveUpdatePlugin.swift, liveUpdateConfig), plus Protobase's `releasesUrl`. They come from
6
- * capacitor.config.json, which ships in the binary outside the web part, beyond page script and a live update.
5
+ * (capacitor-live-update's LiveUpdatePlugin.swift, liveUpdateConfig), plus Protobase's `releasesUrl`, `fingerprint`,
6
+ * `shellVersion` and `builtInOrder`. They come from capacitor.config.json, which ships in the binary outside the web
7
+ * part, beyond page script and a live update.
7
8
  */
8
9
  struct LiveUpdateSettings {
9
10
  var autoUpdateStrategy: String?
10
11
  var autoDeleteBundles: Bool
12
+ var autoBlockRolledBackBundles: Bool
11
13
  var readyTimeout: Int
12
14
  var releasesUrl: String?
13
15
  var publicKey: String?
16
+ var fingerprint: String?
17
+ var shellVersion: String?
18
+ var builtInOrder: String?
14
19
  }
15
20
 
16
21
  extension LiveUpdateSettings {
@@ -18,8 +23,12 @@ extension LiveUpdateSettings {
18
23
  self.init(
19
24
  autoUpdateStrategy: config.getString("autoUpdateStrategy", "none"),
20
25
  autoDeleteBundles: config.getBoolean("autoDeleteBundles", false),
26
+ autoBlockRolledBackBundles: config.getBoolean("autoBlockRolledBackBundles", false),
21
27
  readyTimeout: config.getInt("readyTimeout", 0),
22
28
  releasesUrl: config.getString("releasesUrl"),
23
- publicKey: config.getString("publicKey"))
29
+ publicKey: config.getString("publicKey"),
30
+ fingerprint: config.getString("fingerprint"),
31
+ shellVersion: config.getString("shellVersion"),
32
+ builtInOrder: config.getString("builtInOrder"))
24
33
  }
25
34
  }
@@ -0,0 +1,87 @@
1
+ import Foundation
2
+
3
+ /** Files the guard reads and writes in folders it owns, never through a link: each is checked with lstat and opened with O_NOFOLLOW. */
4
+ enum LocalFile {
5
+ enum Kind {
6
+ case file, folder, link, other, missing
7
+ }
8
+
9
+ static func kind(_ path: String) -> Kind {
10
+ var info = stat()
11
+ if lstat(path, &info) != 0 { return .missing }
12
+ switch info.st_mode & S_IFMT {
13
+ case S_IFREG: return .file
14
+ case S_IFDIR: return .folder
15
+ case S_IFLNK: return .link
16
+ default: return .other
17
+ }
18
+ }
19
+
20
+ // What was opened, not what the path names by now.
21
+ private static func isFile(_ fd: Int32) -> Bool {
22
+ var info = stat()
23
+ return fstat(fd, &info) == 0 && info.st_mode & S_IFMT == S_IFREG
24
+ }
25
+
26
+ /** The bytes of the regular file `path`, at most `limit`. */
27
+ static func read(_ path: String, limit: Int, _ what: String) throws -> Data {
28
+ let fd = open(path, O_RDONLY | O_NOFOLLOW)
29
+ if fd < 0 { throw BundleRefused("\(what) is missing") }
30
+ let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
31
+ guard isFile(fd) else { throw BundleRefused("\(what) is no file") }
32
+ let data = handle.readData(ofLength: limit + 1)
33
+ if data.count > limit { throw BundleRefused("\(what) is larger than \(limit) bytes") }
34
+ return data
35
+ }
36
+
37
+ /** Hands the regular file `path` to `body` in chunks. */
38
+ static func stream(_ path: String, _ what: String, _ body: (Data) throws -> Void) throws {
39
+ let fd = open(path, O_RDONLY | O_NOFOLLOW)
40
+ if fd < 0 { throw BundleRefused("\(what) could not be read") }
41
+ let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
42
+ guard isFile(fd) else { throw BundleRefused("\(what) is no file") }
43
+ while true {
44
+ let chunk = handle.readData(ofLength: 64 * 1024)
45
+ if chunk.isEmpty { return }
46
+ try body(chunk)
47
+ }
48
+ }
49
+
50
+ /** Writes `data` to the new file `path`. */
51
+ static func create(_ path: String, _ data: Data) throws {
52
+ let sink = try FileSink(path, limit: Int64(data.count))
53
+ try sink.write(data)
54
+ _ = try sink.finish()
55
+ }
56
+ }
57
+
58
+ /**
59
+ * A new file the guard writes, hashed as it is written and holding at most `limit` bytes: created exclusively and
60
+ * without following a link, so it never writes through one or over a file already there.
61
+ */
62
+ final class FileSink {
63
+ private let handle: FileHandle
64
+ private let limit: Int64
65
+ private var hasher = Sha256Hasher()
66
+ private(set) var size: Int64 = 0
67
+
68
+ init(_ path: String, limit: Int64) throws {
69
+ let fd = open(path, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0o600)
70
+ if fd < 0 { throw BundleRefused("\((path as NSString).lastPathComponent) could not be created") }
71
+ handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
72
+ self.limit = limit
73
+ }
74
+
75
+ func write(_ data: Data) throws {
76
+ size += Int64(data.count)
77
+ if size > limit { throw BundleRefused("a file is larger than its manifest says") }
78
+ hasher.update(data)
79
+ try handle.write(contentsOf: data)
80
+ }
81
+
82
+ /** The sha256 of what was written. */
83
+ func finish() throws -> String {
84
+ try handle.close()
85
+ return hasher.finish()
86
+ }
87
+ }
@@ -0,0 +1,29 @@
1
+ import Foundation
2
+
3
+ /**
4
+ * The files of the bundle the app runs, by checksum, which a download copies instead of fetching: a downloaded
5
+ * bundle's folder, or the binary's own files, as the manifest beside them lists them. A copy is hashed again as it
6
+ * is written, so it is bound by its content to the release's manifest, never by where it came from.
7
+ */
8
+ struct OwnFiles {
9
+ /** None: every file is fetched. */
10
+ static let none = OwnFiles(files: [:])
11
+
12
+ private let files: [String: String]
13
+
14
+ /** The files of `dir` its own manifest lists, regular files only; none when it has no manifest that parses. */
15
+ static func inFolder(_ dir: String) -> OwnFiles {
16
+ guard let bytes = try? LocalFile.read(dir + "/" + ReleaseManifest.file, limit: ReleaseManifest.maxBytes, "its manifest"),
17
+ let manifest = try? ReleaseManifest.parse(bytes) else { return none }
18
+ var files: [String: String] = [:]
19
+ for item in manifest.items where LocalFile.kind(dir + "/" + item.href) == .file {
20
+ files[item.checksum] = dir + "/" + item.href
21
+ }
22
+ return OwnFiles(files: files)
23
+ }
24
+
25
+ /** The path of a file of checksum `checksum`, or nil when the bundle has none. */
26
+ func path(_ checksum: String) -> String? {
27
+ files[checksum]
28
+ }
29
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * This binary: its app id, its native fingerprint and the Protobase version of its shell, from the app's bundle and
3
+ * capacitor.config.json, beyond page script and a live update. It takes a release only for itself.
4
+ */
5
+ struct ReleaseBinary {
6
+ let appId: String
7
+ let fingerprint: String
8
+ let shellVersion: String
9
+
10
+ /**
11
+ * Refuses a release for another app or fingerprint, or of a server whose API this shell does not work with: the same
12
+ * major, and below 1.0 the same minor, as the server's `shellUpdateRequired` says.
13
+ */
14
+ func accept(_ statement: ReleaseStatement) throws {
15
+ if statement.appId != appId { throw BundleRefused("it is a release of another app") }
16
+ if statement.fingerprint != fingerprint { throw BundleRefused("it is a release for another binary") }
17
+ let own = ReleaseStatement.majorMinor(shellVersion)
18
+ let server = ReleaseStatement.majorMinor(statement.shellVersion)
19
+ if own.0 != server.0 || (server.0 == 0 && own.1 != server.1) { throw BundleRefused("it is a release for another shell version") }
20
+ }
21
+ }
@@ -0,0 +1,55 @@
1
+ import Foundation
2
+
3
+ /**
4
+ * Downloads release `bundleId` from `releasesUrl/<id>?href=<file>` into a folder, in place of the stock downloader,
5
+ * which joins a manifest's hrefs to its path unchecked and checks each file only against its own signature
6
+ * (LiveUpdate.swift, downloadBundleFiles and verifyFile, of @capawesome/capacitor-live-update 8.7.1). First the
7
+ * statement, checked against the id, the key, this binary and the highest order the app downloaded; then the manifest,
8
+ * checked against the statement and every href before any file is fetched; then each file, hashed as it is written
9
+ * and refused unless it is the one the manifest names. A file the running bundle has with the same checksum
10
+ * (OwnFiles) is copied from it and hashed again, so it is bound by its content, never by its href.
11
+ */
12
+ struct ReleaseDownload {
13
+ let source: ReleaseSource
14
+ let key: ReleaseKey
15
+ let binary: ReleaseBinary
16
+
17
+ /**
18
+ * Fills the empty folder `dir` with release `bundleId`, copying what `own` has, and checks it as a whole: its order
19
+ * above the built-in files' `builtIn` and not below `highest`, the highest the app downloaded. Returns its statement.
20
+ */
21
+ func download(_ bundleId: String, into dir: String, own: OwnFiles, builtIn: [Int64], highest: [Int64]) throws -> ReleaseStatement {
22
+ let statementBytes = try fetch(bundleId, ReleaseStatement.file, limit: ReleaseStatement.maxBytes)
23
+ let statement = try ReleaseStatement.verify(statementBytes, bundleId: bundleId, key: key)
24
+ try binary.accept(statement)
25
+ try ReleaseOrder.acceptDownload(statement.order, builtIn: builtIn, highest: highest)
26
+ let manifestBytes = try fetch(bundleId, ReleaseManifest.file, limit: ReleaseManifest.maxBytes)
27
+ let manifest = try ReleaseManifest.verify(manifestBytes, statement: statement)
28
+ for item in manifest.items {
29
+ let target = dir + "/" + item.href
30
+ try FileManager.default.createDirectory(atPath: (target as NSString).deletingLastPathComponent, withIntermediateDirectories: true)
31
+ if let copy = own.path(item.checksum), (try? write(target, item) { sink in try LocalFile.stream(copy, item.href) { try sink.write($0) } }) == true {
32
+ continue
33
+ }
34
+ unlink(target)
35
+ let fetched = try write(target, item) { sink in try source.fetch(bundleId: bundleId, href: item.href, limit: item.size) { try sink.write($0) } }
36
+ if !fetched { throw BundleRefused("its \(item.href) is not the file its manifest names") }
37
+ }
38
+ try LocalFile.create(dir + "/" + ReleaseStatement.file, statementBytes)
39
+ try LocalFile.create(dir + "/" + ReleaseManifest.file, manifestBytes)
40
+ return try BundleCheck.check(dir, bundleId: bundleId, key: key, binary: binary)
41
+ }
42
+
43
+ // Writes the new file `target` from `fill`; whether it is exactly the file `item` names.
44
+ private func write(_ target: String, _ item: ReleaseManifest.Item, _ fill: (FileSink) throws -> Void) throws -> Bool {
45
+ let sink = try FileSink(target, limit: item.size)
46
+ try fill(sink)
47
+ return try sink.finish() == item.checksum && sink.size == item.size
48
+ }
49
+
50
+ private func fetch(_ bundleId: String, _ href: String, limit: Int) throws -> Data {
51
+ var data = Data()
52
+ try source.fetch(bundleId: bundleId, href: href, limit: Int64(limit)) { data.append($0) }
53
+ return data
54
+ }
55
+ }
@@ -0,0 +1,100 @@
1
+ import Foundation
2
+
3
+ /** Where a download takes a release's files from: `releasesUrl/<id>?href=<file>`, at most `limit` bytes, in chunks. */
4
+ protocol ReleaseSource {
5
+ func fetch(bundleId: String, href: String, limit: Int64, into sink: @escaping (Data) throws -> Void) throws
6
+ }
7
+
8
+ /** A release could not be fetched: the connection failed or timed out. */
9
+ struct ReleaseFetchFailed: Error {}
10
+
11
+ /**
12
+ * Fetches a release's file from the app's own releases URL over a session of its own, with no cache or cookies: only a
13
+ * 200 of the releases URL itself is an answer, a redirect is never followed, and the transfer stops the moment it
14
+ * passes its limit. Blocks its caller, which must not be the main thread, until the file is in or refused.
15
+ */
16
+ final class ReleaseFetch: ReleaseSource {
17
+ private static let requestTimeout: TimeInterval = 30
18
+ private static let resourceTimeout: TimeInterval = 600
19
+ // RFC 3986's unreserved characters; every other byte of an href is percent-encoded, a space as %20.
20
+ private static let unreserved = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
21
+
22
+ private let releasesUrl: String
23
+ private let configuration: URLSessionConfiguration
24
+
25
+ init(releasesUrl: String, configuration: URLSessionConfiguration = .ephemeral) {
26
+ self.releasesUrl = releasesUrl
27
+ configuration.urlCache = nil
28
+ configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
29
+ configuration.httpCookieStorage = nil
30
+ configuration.httpShouldSetCookies = false
31
+ configuration.timeoutIntervalForRequest = Self.requestTimeout
32
+ configuration.timeoutIntervalForResource = Self.resourceTimeout
33
+ self.configuration = configuration
34
+ }
35
+
36
+ func url(bundleId: String, href: String) -> URL? {
37
+ guard let query = href.addingPercentEncoding(withAllowedCharacters: Self.unreserved) else { return nil }
38
+ return URL(string: releasesUrl + "/" + bundleId + "?href=" + query)
39
+ }
40
+
41
+ func fetch(bundleId: String, href: String, limit: Int64, into sink: @escaping (Data) throws -> Void) throws {
42
+ guard let url = url(bundleId: bundleId, href: href) else { throw BundleRefused("its \(href) has no address") }
43
+ let transfer = Transfer(href: href, limit: limit, sink: sink)
44
+ let queue = OperationQueue()
45
+ queue.maxConcurrentOperationCount = 1
46
+ let session = URLSession(configuration: configuration, delegate: transfer, delegateQueue: queue)
47
+ session.dataTask(with: url).resume()
48
+ transfer.done.wait()
49
+ session.invalidateAndCancel()
50
+ if let failure = transfer.failure { throw failure }
51
+ }
52
+ }
53
+
54
+ /** One fetch's state, read and written only on its session's serial delegate queue until `done` is signalled. */
55
+ private final class Transfer: NSObject, URLSessionDataDelegate {
56
+ let href: String
57
+ let limit: Int64
58
+ let sink: (Data) throws -> Void
59
+ let done = DispatchSemaphore(value: 0)
60
+ var received: Int64 = 0
61
+ var failure: Error?
62
+
63
+ init(href: String, limit: Int64, sink: @escaping (Data) throws -> Void) {
64
+ self.href = href
65
+ self.limit = limit
66
+ self.sink = sink
67
+ }
68
+
69
+ // A redirect is no answer: with nil the 3xx itself arrives as the response, which is refused below.
70
+ func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
71
+ completionHandler(nil)
72
+ }
73
+
74
+ func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
75
+ let status = (response as? HTTPURLResponse)?.statusCode ?? 0
76
+ if status == 200 { return completionHandler(.allow) }
77
+ failure = BundleRefused("the server answered \(status) for \(href)")
78
+ completionHandler(.cancel)
79
+ }
80
+
81
+ func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
82
+ if failure != nil { return }
83
+ received += Int64(data.count)
84
+ if received > limit {
85
+ failure = BundleRefused("its \(href) is larger than \(limit) bytes")
86
+ return dataTask.cancel()
87
+ }
88
+ do {
89
+ try sink(data)
90
+ } catch {
91
+ failure = error
92
+ dataTask.cancel()
93
+ }
94
+ }
95
+
96
+ func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
97
+ if failure == nil && error != nil { failure = ReleaseFetchFailed() }
98
+ done.signal()
99
+ }
100
+ }
@@ -0,0 +1,79 @@
1
+ import Foundation
2
+ import Security
3
+
4
+ /**
5
+ * The key releases are signed with, `plugins.LiveUpdate.publicKey`: an RSA public key as base64 SPKI DER, with or
6
+ * without PEM's lines, as the CLI writes it. The SPKI is read here, so a key of another type is refused rather than
7
+ * left to Security; its DER is kept, since a release's id hashes it.
8
+ */
9
+ struct ReleaseKey {
10
+ let key: SecKey
11
+ let spki: Data
12
+
13
+ // rsaEncryption, 1.2.840.113549.1.1.1.
14
+ private static let rsaOid: [UInt8] = [0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01]
15
+
16
+ /** The key `text` holds, or nil when it is no RSA SPKI. */
17
+ static func parse(_ text: String?) -> ReleaseKey? {
18
+ guard let text else { return nil }
19
+ let body = text.replacingOccurrences(of: "-----BEGIN PUBLIC KEY-----", with: "")
20
+ .replacingOccurrences(of: "-----END PUBLIC KEY-----", with: "")
21
+ .filter { !$0.isWhitespace }
22
+ guard !body.isEmpty, let spki = Data(base64Encoded: body), let pkcs1 = rsaPublicKey(inSpki: [UInt8](spki)) else { return nil }
23
+ let attributes: [CFString: Any] = [kSecAttrKeyType: kSecAttrKeyTypeRSA, kSecAttrKeyClass: kSecAttrKeyClassPublic]
24
+ guard let key = SecKeyCreateWithData(Data(pkcs1) as CFData, attributes as CFDictionary, nil) else { return nil }
25
+ return ReleaseKey(key: key, spki: spki)
26
+ }
27
+
28
+ /** Whether `key` signed `message`, RSA PKCS#1 v1.5 with SHA-256, as the server's `sign('sha256', …)`. */
29
+ func signed(_ message: Data, _ signature: Data) -> Bool {
30
+ SecKeyVerifySignature(key, .rsaSignatureMessagePKCS1v15SHA256, message as CFData, signature as CFData, nil)
31
+ }
32
+
33
+ // SEQUENCE { SEQUENCE { OID rsaEncryption, NULL }, BIT STRING { 0 unused bits, RSAPublicKey } }, nothing after it.
34
+ private static func rsaPublicKey(inSpki der: [UInt8]) -> [UInt8]? {
35
+ var outer = Der(der)
36
+ guard var spki = outer.read(0x30), outer.atEnd else { return nil }
37
+ guard var algorithm = spki.read(0x30), let oid = algorithm.read(0x06), oid.rest == rsaOid else { return nil }
38
+ if !algorithm.atEnd { guard let null = algorithm.read(0x05), null.atEnd, algorithm.atEnd else { return nil } }
39
+ guard let bits = spki.read(0x03), spki.atEnd, bits.rest.first == 0 else { return nil }
40
+ let key = Array(bits.rest.dropFirst())
41
+ var check = Der(key)
42
+ guard check.read(0x30) != nil, check.atEnd else { return nil }
43
+ return key
44
+ }
45
+ }
46
+
47
+ /** A DER reader over definite lengths only, which is all DER allows. */
48
+ private struct Der {
49
+ private let bytes: [UInt8]
50
+ private var at = 0
51
+
52
+ init(_ bytes: [UInt8]) {
53
+ self.bytes = bytes
54
+ }
55
+
56
+ var atEnd: Bool { at == bytes.count }
57
+ var rest: [UInt8] { Array(bytes[at...]) }
58
+
59
+ /** The contents of the next element when its tag is `tag`, moving past it. */
60
+ mutating func read(_ tag: UInt8) -> Der? {
61
+ guard at + 2 <= bytes.count, bytes[at] == tag else { return nil }
62
+ var i = at + 1
63
+ var length = Int(bytes[i])
64
+ i += 1
65
+ if length & 0x80 != 0 {
66
+ let count = length & 0x7f
67
+ guard count >= 1, count <= 4, i + count <= bytes.count else { return nil }
68
+ length = 0
69
+ for _ in 0..<count {
70
+ length = length << 8 | Int(bytes[i])
71
+ i += 1
72
+ }
73
+ }
74
+ guard length <= bytes.count - i else { return nil }
75
+ let contents = Der(Array(bytes[i..<(i + length)]))
76
+ at = i + length
77
+ return contents
78
+ }
79
+ }
@@ -0,0 +1,72 @@
1
+ import Foundation
2
+
3
+ /**
4
+ * A release's manifest (`releaseManifestFile` in @protobase/schema): every file by `href`, with its sha256 and size.
5
+ * Each href is checked as the very string the guard joins to a folder, before any file of it is read or written.
6
+ */
7
+ struct ReleaseManifest {
8
+ static let file = "capawesome-live-update-manifest.json"
9
+ static let maxBytes = 4 * 1024 * 1024
10
+ static let maxFiles = 10_000
11
+ static let maxFileBytes: Int64 = 64 * 1024 * 1024
12
+ static let maxTotalBytes: Int64 = 256 * 1024 * 1024
13
+ private static let maxHref = 1024
14
+
15
+ struct Item {
16
+ let href: String
17
+ let checksum: String
18
+ let size: Int64
19
+ }
20
+
21
+ let items: [Item]
22
+
23
+ /** The manifest `bytes` hold, when they are the ones `statement` names. */
24
+ static func verify(_ bytes: Data, statement: ReleaseStatement) throws -> ReleaseManifest {
25
+ if Sha256.of(bytes) != statement.manifest { throw BundleRefused("its manifest is not the one its statement names") }
26
+ return try parse(bytes)
27
+ }
28
+
29
+ /** The manifest `bytes` hold, each href a path inside the folder that holds it. */
30
+ static func parse(_ bytes: Data) throws -> ReleaseManifest {
31
+ if bytes.count > maxBytes { throw BundleRefused("its manifest is too large") }
32
+ guard let entries = Json.parse(bytes) as? [Any] else { throw BundleRefused("its manifest is malformed") }
33
+ if entries.count > maxFiles { throw BundleRefused("its manifest lists too many files") }
34
+ var items: [Item] = []
35
+ var names: Set<String> = [folded(file), folded(ReleaseStatement.file)]
36
+ var total: Int64 = 0
37
+ for entry in entries {
38
+ let item = try self.item(entry)
39
+ // The volume may ignore case and Unicode normalization, where two such hrefs are one file.
40
+ if !names.insert(folded(item.href)).inserted { throw BundleRefused("its manifest lists \(item.href) twice, or a name of the updater's") }
41
+ total += item.size
42
+ if total > maxTotalBytes { throw BundleRefused("its files are too large") }
43
+ items.append(item)
44
+ }
45
+ return ReleaseManifest(items: items)
46
+ }
47
+
48
+ /** `name` as a volume that ignores case and Unicode normalization sees it. */
49
+ static func folded(_ name: String) -> String {
50
+ name.precomposedStringWithCanonicalMapping.folding(options: [.caseInsensitive], locale: nil)
51
+ }
52
+
53
+ private static func item(_ entry: Any) throws -> Item {
54
+ guard let entry = entry as? [String: Any], entry.count == 3, let href = entry["href"] as? String, let checksum = entry["checksum"] as? String else {
55
+ throw BundleRefused("its manifest has an entry that is not an href, checksum and size")
56
+ }
57
+ if !isRelativePath(href) { throw BundleRefused("its manifest names a file outside its folder") }
58
+ if !Hex.isHex(checksum, count: 64) { throw BundleRefused("its manifest names no checksum for \(href)") }
59
+ guard let size = Json.integer(entry["sizeInBytes"]), size >= 0, size <= maxFileBytes else { throw BundleRefused("its manifest names no size for \(href)") }
60
+ return Item(href: href, checksum: checksum, size: size)
61
+ }
62
+
63
+ // Path components joined by `/`: none empty, `.` or `..`, and no `\` or NUL, so neither an absolute path nor one
64
+ // that climbs, nor one another platform reads otherwise. Read as bytes, the way the file system reads it: as
65
+ // characters, a `/` followed by a combining mark is no `/`.
66
+ static func isRelativePath(_ href: String) -> Bool {
67
+ let bytes = Array(href.utf8)
68
+ if bytes.isEmpty || bytes.count > maxHref || bytes.contains(0) || bytes.contains(UInt8(ascii: "\\")) { return false }
69
+ let dot = UInt8(ascii: ".")
70
+ return bytes.split(separator: UInt8(ascii: "/"), omittingEmptySubsequences: false).allSatisfy { !$0.isEmpty && Array($0) != [dot] && Array($0) != [dot, dot] }
71
+ }
72
+ }
@@ -0,0 +1,63 @@
1
+ import Foundation
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
+ enum ReleaseOrder {
8
+ /** No order: below every release, the highest order downloaded before the app downloaded any. */
9
+ static let none: [Int64] = [0]
10
+
11
+ // An order the CLI or the guard writes: up to 8 elements of up to 19 digits, checked for overflow when parsed.
12
+ private static let text = try! NSRegularExpression(pattern: "^[0-9]{1,19}(,[0-9]{1,19}){0,7}$")
13
+
14
+ static func compare(_ a: [Int64], _ b: [Int64]) -> Int {
15
+ for i in 0..<max(a.count, b.count) {
16
+ let x = i < a.count ? a[i] : 0
17
+ let y = i < b.count ? b[i] : 0
18
+ if x != y { return x < y ? -1 : 1 }
19
+ }
20
+ return 0
21
+ }
22
+
23
+ /**
24
+ * Refuses to download a release of order `order` unless it is above the app's built-in files, `builtIn`, and not
25
+ * below the highest order the app downloaded, `highest`: equal to it is the same release again, which the app
26
+ * downloaded and may still have to set as the next bundle.
27
+ */
28
+ static func acceptDownload(_ order: [Int64], builtIn: [Int64], highest: [Int64]) throws {
29
+ if compare(order, builtIn) <= 0 { throw BundleRefused("it is not newer than the app's built-in files") }
30
+ if compare(order, highest) < 0 { throw BundleRefused("it is older than a release the app downloaded") }
31
+ }
32
+
33
+ /**
34
+ * Refuses a bundle of order `next` as the next bundle unless it is above the running one's, `current`, and the
35
+ * built-in files', `builtIn`, and not below the highest order the app downloaded, `highest`.
36
+ */
37
+ static func acceptNext(_ next: [Int64], current: [Int64], builtIn: [Int64], highest: [Int64]) throws {
38
+ if compare(next, current) <= 0 { throw BundleRefused("it is not newer than the bundle the app runs") }
39
+ if compare(next, builtIn) <= 0 { throw BundleRefused("it is not newer than the app's built-in files") }
40
+ if compare(next, highest) < 0 { throw BundleRefused("it is older than a release the app downloaded") }
41
+ }
42
+
43
+ static func format(_ order: [Int64]) -> String {
44
+ order.map(String.init).joined(separator: ",")
45
+ }
46
+
47
+ /** The order `text` holds, or nil when it is no order's text. */
48
+ static func parse(_ text: String) -> [Int64]? {
49
+ guard self.text.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)) != nil else { return nil }
50
+ let parts = text.split(separator: ",").map { Int64($0) }
51
+ guard parts.allSatisfy({ $0 != nil }) else { return nil }
52
+ return parts.map { $0! }
53
+ }
54
+
55
+ /**
56
+ * The order the CLI wrote, `plugins.LiveUpdate.builtInOrder`, or nil when it is missing, malformed or no order above
57
+ * none, so the guard never runs with a floor of 0.
58
+ */
59
+ static func parseBuiltIn(_ text: String?) -> [Int64]? {
60
+ guard let text, let order = parse(text), compare(order, none) > 0 else { return nil }
61
+ return order
62
+ }
63
+ }
@@ -0,0 +1,60 @@
1
+ import Foundation
2
+
3
+ /**
4
+ * A release's statement (`releaseStatementFile` in @protobase/schema): what binds its manifest to its order, the app,
5
+ * the binary and the shell, signed with the app's key. Only bytes the signature covers are parsed for what they say.
6
+ */
7
+ struct ReleaseStatement {
8
+ static let file = "protobase-release.json"
9
+ static let maxBytes = 64 * 1024
10
+ private static let context = Data("protobase-mobile-release\n".utf8)
11
+ private static let keys: Set<String> = ["manifest", "order", "appId", "fingerprint", "shellVersion"]
12
+ private static let version = try! NSRegularExpression(pattern: "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$")
13
+ private static let maxOrder = 8
14
+
15
+ let manifest: String
16
+ let order: [Int64]
17
+ let appId: String
18
+ let fingerprint: String
19
+ let shellVersion: String
20
+
21
+ /**
22
+ * The statement `file` holds, when it is the one bundle `bundleId` names, the first 16 hex of the sha256 of its bytes
23
+ * and of `key`'s SPKI DER, as `composeRelease` derives it, and `key` signed what it says.
24
+ */
25
+ static func verify(_ file: Data, bundleId: String, key: ReleaseKey) throws -> ReleaseStatement {
26
+ if file.count > maxBytes { throw BundleRefused("its statement is too large") }
27
+ if Sha256.of(file, key.spki).prefix(16) != bundleId { throw BundleRefused("its id does not name its statement and the app's key") }
28
+ guard let outer = Json.parse(file) as? [String: Any], let payloadHex = outer["payload"] as? String, let signatureHex = outer["signature"] as? String else {
29
+ throw BundleRefused("its statement is malformed")
30
+ }
31
+ let payload = try Hex.decode(payloadHex, "its statement's payload")
32
+ let signature = try Hex.decode(signatureHex, "its statement's signature")
33
+ if !key.signed(context + payload, signature) { throw BundleRefused("its statement is not signed with the app's key") }
34
+ guard let fields = Json.parse(payload) as? [String: Any] else { throw BundleRefused("its statement says nothing it can read") }
35
+ return try parse(fields)
36
+ }
37
+
38
+ private static func parse(_ payload: [String: Any]) throws -> ReleaseStatement {
39
+ if Set(payload.keys) != keys { throw BundleRefused("its statement does not say exactly what binds it") }
40
+ guard let manifest = payload["manifest"] as? String, Hex.isHex(manifest, count: 64) else { throw BundleRefused("its statement names no manifest hash") }
41
+ guard let appId = payload["appId"] as? String, !appId.isEmpty else { throw BundleRefused("its statement names no app") }
42
+ guard let fingerprint = payload["fingerprint"] as? String, Hex.isHex(fingerprint, count: 16) else { throw BundleRefused("its statement names no fingerprint") }
43
+ guard let shellVersion = payload["shellVersion"] as? String, isVersion(shellVersion) else { throw BundleRefused("its statement names no shell version") }
44
+ guard let parts = payload["order"] as? [Any], (1...maxOrder).contains(parts.count) else { throw BundleRefused("its statement names no order") }
45
+ let order = parts.map(Json.integer)
46
+ guard order.allSatisfy({ ($0 ?? -1) >= 0 }) else { throw BundleRefused("its statement names no order") }
47
+ return ReleaseStatement(manifest: manifest, order: order.map { $0! }, appId: appId, fingerprint: fingerprint, shellVersion: shellVersion)
48
+ }
49
+
50
+ static func isVersion(_ text: String?) -> Bool {
51
+ guard let text else { return false }
52
+ return version.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)) != nil
53
+ }
54
+
55
+ /** The major and minor of an x.y.z version `isVersion` accepted. */
56
+ static func majorMinor(_ version: String) -> (Int64, Int64) {
57
+ let parts = version.split(separator: ".").map { Int64($0) ?? -1 }
58
+ return (parts[0], parts[1])
59
+ }
60
+ }
@@ -0,0 +1,31 @@
1
+ import CryptoKit
2
+ import Foundation
3
+
4
+ /** sha256 as lowercase hex, of bytes or of a file read in chunks. */
5
+ enum Sha256 {
6
+ static func of(_ parts: Data...) -> String {
7
+ var hasher = SHA256()
8
+ for part in parts { hasher.update(data: part) }
9
+ return Hex.encode(hasher.finalize())
10
+ }
11
+
12
+ /** The sha256 of the regular file `path`, never read through a link. */
13
+ static func of(file path: String) throws -> String {
14
+ var hasher = Sha256Hasher()
15
+ try LocalFile.stream(path, (path as NSString).lastPathComponent) { hasher.update($0) }
16
+ return hasher.finish()
17
+ }
18
+ }
19
+
20
+ /** sha256 over chunks. */
21
+ struct Sha256Hasher {
22
+ private var hasher = SHA256()
23
+
24
+ mutating func update(_ data: Data) {
25
+ hasher.update(data: data)
26
+ }
27
+
28
+ func finish() -> String {
29
+ Hex.encode(hasher.finalize())
30
+ }
31
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/mobile",
3
- "version": "0.57.4",
3
+ "version": "0.57.6",
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": 7,
25
+ "nativeContract": 8,
26
26
  "capacitor": {
27
27
  "ios": {
28
28
  "src": "ios"