@otakit/capacitor-updater 2.1.2 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +142 -4
- package/android/src/main/java/com/otakit/updater/BundleCrypto.java +81 -0
- package/android/src/main/java/com/otakit/updater/BundleStore.java +26 -0
- package/android/src/main/java/com/otakit/updater/DeltaAssembler.java +416 -0
- package/android/src/main/java/com/otakit/updater/HashUtils.java +16 -0
- package/android/src/main/java/com/otakit/updater/ManifestClient.java +136 -4
- package/android/src/main/java/com/otakit/updater/ManifestVerifier.java +43 -0
- package/android/src/main/java/com/otakit/updater/UpdaterPlugin.java +457 -20
- package/dist/esm/definitions.d.ts +104 -4
- package/dist/esm/definitions.d.ts.map +1 -1
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +6 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/web.d.ts +7 -1
- package/dist/esm/web.d.ts.map +1 -1
- package/dist/esm/web.js +25 -0
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +31 -0
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +31 -0
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/UpdaterPlugin/BundleCrypto.swift +91 -0
- package/ios/Sources/UpdaterPlugin/BundleStore.swift +30 -0
- package/ios/Sources/UpdaterPlugin/DeltaAssembler.swift +316 -0
- package/ios/Sources/UpdaterPlugin/ManifestClient.swift +98 -6
- package/ios/Sources/UpdaterPlugin/ManifestVerifier.swift +29 -0
- package/ios/Sources/UpdaterPlugin/UpdaterPlugin.swift +422 -21
- package/package.json +1 -1
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import CryptoKit
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
enum DeltaAssemblerError: Error, LocalizedError {
|
|
5
|
+
case missingFiles
|
|
6
|
+
case invalidPath(String)
|
|
7
|
+
case duplicatePath(String)
|
|
8
|
+
case fileCountExceeded(Int)
|
|
9
|
+
case totalSizeExceeded(UInt64)
|
|
10
|
+
case filesHashMismatch
|
|
11
|
+
case fileHashMismatch(String)
|
|
12
|
+
case missingIndexHtml
|
|
13
|
+
case invalidFileURL(String)
|
|
14
|
+
|
|
15
|
+
var errorDescription: String? {
|
|
16
|
+
switch self {
|
|
17
|
+
case .missingFiles:
|
|
18
|
+
return "Delta manifest has no files"
|
|
19
|
+
case let .invalidPath(path):
|
|
20
|
+
return "Invalid file path in delta manifest: \(path)"
|
|
21
|
+
case let .duplicatePath(path):
|
|
22
|
+
return "Duplicate file path in delta manifest: \(path)"
|
|
23
|
+
case let .fileCountExceeded(count):
|
|
24
|
+
return "Delta manifest exceeds file count limit: \(count)"
|
|
25
|
+
case let .totalSizeExceeded(size):
|
|
26
|
+
return "Delta manifest exceeds total size limit: \(size)"
|
|
27
|
+
case .filesHashMismatch:
|
|
28
|
+
return "Delta file list does not match the signed filesHash"
|
|
29
|
+
case let .fileHashMismatch(path):
|
|
30
|
+
return "Downloaded file hash mismatch: \(path)"
|
|
31
|
+
case .missingIndexHtml:
|
|
32
|
+
return "Delta bundle does not contain index.html"
|
|
33
|
+
case let .invalidFileURL(url):
|
|
34
|
+
return "Invalid file download URL: \(url)"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// Assembles a delta-strategy bundle from per-file content-addressed objects.
|
|
40
|
+
///
|
|
41
|
+
/// The content cache (`otakit_files/<sha256>`) is the device-side state:
|
|
42
|
+
/// previous bundles and the builtin seed populate it, and assembling a new
|
|
43
|
+
/// bundle downloads only the cache misses.
|
|
44
|
+
final class DeltaAssembler {
|
|
45
|
+
// Mirror ZipUtils' extraction limits.
|
|
46
|
+
private let maxFiles = 10_000
|
|
47
|
+
private let maxTotalSize: UInt64 = 500_000_000 // 500 MB
|
|
48
|
+
|
|
49
|
+
private let cacheDirectory: URL
|
|
50
|
+
private let downloader: Downloader
|
|
51
|
+
private let fileManager = FileManager.default
|
|
52
|
+
|
|
53
|
+
private static let builtinSeedMarkerName = "builtin_seed.json"
|
|
54
|
+
|
|
55
|
+
init(cacheDirectory: URL, downloader: Downloader) {
|
|
56
|
+
self.cacheDirectory = cacheDirectory
|
|
57
|
+
self.downloader = downloader
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// MARK: - Canonical file list
|
|
61
|
+
|
|
62
|
+
/// Canonical file list hash — must match the server's computeFilesHash
|
|
63
|
+
/// (console/lib/delta-files.ts) and the Android mirror byte-for-byte:
|
|
64
|
+
/// entries sorted by UTF-8 bytes of path, lines "<path>:<sha256 lowercase>",
|
|
65
|
+
/// joined with "\n", hashed with SHA-256 (hex).
|
|
66
|
+
static func computeFilesHash(_ entries: [ManifestFileEntry]) -> String {
|
|
67
|
+
let sorted = entries.sorted { lhs, rhs in
|
|
68
|
+
let lhsBytes = Array(lhs.path.utf8)
|
|
69
|
+
let rhsBytes = Array(rhs.path.utf8)
|
|
70
|
+
for index in 0..<min(lhsBytes.count, rhsBytes.count) {
|
|
71
|
+
if lhsBytes[index] != rhsBytes[index] {
|
|
72
|
+
return lhsBytes[index] < rhsBytes[index]
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return lhsBytes.count < rhsBytes.count
|
|
76
|
+
}
|
|
77
|
+
let canonical = sorted
|
|
78
|
+
.map { "\($0.path):\($0.sha256.lowercased())" }
|
|
79
|
+
.joined(separator: "\n")
|
|
80
|
+
let digest = SHA256.hash(data: Data(canonical.utf8))
|
|
81
|
+
return digest.map { String(format: "%02x", $0) }.joined()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// MARK: - Validation
|
|
85
|
+
|
|
86
|
+
private func isValidEntryPath(_ path: String) -> Bool {
|
|
87
|
+
if path.isEmpty || path.count > 512 {
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
if path.hasPrefix("/") || path.contains("\\") {
|
|
91
|
+
return false
|
|
92
|
+
}
|
|
93
|
+
for segment in path.split(separator: "/", omittingEmptySubsequences: false) {
|
|
94
|
+
if segment.isEmpty || segment == "." || segment == ".." {
|
|
95
|
+
return false
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
for scalar in path.unicodeScalars {
|
|
99
|
+
if scalar.value < 0x20 || scalar.value == 0x7f {
|
|
100
|
+
return false
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Metadata files the plugin writes into the bundle directory; an app
|
|
104
|
+
// file with the same root-level name would be overwritten.
|
|
105
|
+
if path == "bundle.json" || path == "otakit_files.json" {
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
return true
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
func validate(_ entries: [ManifestFileEntry], expectedFilesHash: String) throws {
|
|
112
|
+
guard !entries.isEmpty else {
|
|
113
|
+
throw DeltaAssemblerError.missingFiles
|
|
114
|
+
}
|
|
115
|
+
guard entries.count <= maxFiles else {
|
|
116
|
+
throw DeltaAssemblerError.fileCountExceeded(entries.count)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Key on UTF-8 bytes, not String: Swift compares canonically-equivalent
|
|
120
|
+
// strings (NFC vs NFD) as equal, which would reject a manifest the
|
|
121
|
+
// server and Android both accept.
|
|
122
|
+
var seenPaths = Set<Data>()
|
|
123
|
+
var totalSize: UInt64 = 0
|
|
124
|
+
for entry in entries {
|
|
125
|
+
guard isValidEntryPath(entry.path) else {
|
|
126
|
+
throw DeltaAssemblerError.invalidPath(entry.path)
|
|
127
|
+
}
|
|
128
|
+
guard seenPaths.insert(Data(entry.path.utf8)).inserted else {
|
|
129
|
+
throw DeltaAssemblerError.duplicatePath(entry.path)
|
|
130
|
+
}
|
|
131
|
+
if let size = entry.size, size > 0 {
|
|
132
|
+
totalSize += UInt64(size)
|
|
133
|
+
if totalSize > maxTotalSize {
|
|
134
|
+
throw DeltaAssemblerError.totalSizeExceeded(totalSize)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
guard seenPaths.contains(Data("index.html".utf8)) else {
|
|
140
|
+
throw DeltaAssemblerError.missingIndexHtml
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The signed manifest sha256 is the filesHash; recomputing it here is what
|
|
144
|
+
// extends signature coverage to every (path, sha256) pair.
|
|
145
|
+
guard DeltaAssembler.computeFilesHash(entries) == expectedFilesHash.lowercased() else {
|
|
146
|
+
throw DeltaAssemblerError.filesHashMismatch
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// MARK: - Cache
|
|
151
|
+
|
|
152
|
+
private func cachePath(for sha256: String) -> URL {
|
|
153
|
+
cacheDirectory.appendingPathComponent(sha256.lowercased(), isDirectory: false)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private func isCached(_ sha256: String) -> Bool {
|
|
157
|
+
fileManager.fileExists(atPath: cachePath(for: sha256).path)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private func ensureCached(_ entry: ManifestFileEntry) async throws {
|
|
161
|
+
if isCached(entry.sha256) {
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
guard let url = URL(string: entry.url) else {
|
|
166
|
+
throw DeltaAssemblerError.invalidFileURL(entry.url)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let temporary = try await downloader.download(from: url)
|
|
170
|
+
defer { try? fileManager.removeItem(at: temporary) }
|
|
171
|
+
|
|
172
|
+
guard try HashUtils.verify(fileURL: temporary, expectedSha256: entry.sha256) else {
|
|
173
|
+
throw DeltaAssemblerError.fileHashMismatch(entry.path)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let destination = cachePath(for: entry.sha256)
|
|
177
|
+
if fileManager.fileExists(atPath: destination.path) {
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
// Write via temp + rename so a crash mid-copy can never leave a
|
|
181
|
+
// truncated file at a content-addressed path (exists() implies valid).
|
|
182
|
+
let staging = cacheDirectory.appendingPathComponent(
|
|
183
|
+
".tmp-\(UUID().uuidString)",
|
|
184
|
+
isDirectory: false
|
|
185
|
+
)
|
|
186
|
+
try fileManager.copyItem(at: temporary, to: staging)
|
|
187
|
+
do {
|
|
188
|
+
try fileManager.moveItem(at: staging, to: destination)
|
|
189
|
+
} catch {
|
|
190
|
+
try? fileManager.removeItem(at: staging)
|
|
191
|
+
// A concurrent writer may have won the rename; that's fine.
|
|
192
|
+
if !fileManager.fileExists(atPath: destination.path) {
|
|
193
|
+
throw error
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// MARK: - Assembly
|
|
199
|
+
|
|
200
|
+
/// Fill cache misses and lay out the bundle directory from the cache.
|
|
201
|
+
/// `destination` must be an empty/absent directory; entries must be
|
|
202
|
+
/// validated first.
|
|
203
|
+
func assemble(entries: [ManifestFileEntry], into destination: URL) async throws {
|
|
204
|
+
if fileManager.fileExists(atPath: destination.path) {
|
|
205
|
+
try fileManager.removeItem(at: destination)
|
|
206
|
+
}
|
|
207
|
+
try fileManager.createDirectory(at: destination, withIntermediateDirectories: true)
|
|
208
|
+
|
|
209
|
+
let destinationPrefix = destination.standardizedFileURL.path.hasSuffix("/")
|
|
210
|
+
? destination.standardizedFileURL.path
|
|
211
|
+
: destination.standardizedFileURL.path + "/"
|
|
212
|
+
|
|
213
|
+
for entry in entries {
|
|
214
|
+
try await ensureCached(entry)
|
|
215
|
+
|
|
216
|
+
let target = destination.appendingPathComponent(entry.path, isDirectory: false)
|
|
217
|
+
// Defense in depth alongside isValidEntryPath (mirrors ZipUtils).
|
|
218
|
+
guard target.standardizedFileURL.path.hasPrefix(destinationPrefix) else {
|
|
219
|
+
throw DeltaAssemblerError.invalidPath(entry.path)
|
|
220
|
+
}
|
|
221
|
+
let parent = target.deletingLastPathComponent()
|
|
222
|
+
try fileManager.createDirectory(at: parent, withIntermediateDirectories: true)
|
|
223
|
+
try fileManager.copyItem(at: cachePath(for: entry.sha256), to: target)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// MARK: - Builtin seeding
|
|
228
|
+
|
|
229
|
+
private struct BuiltinSeed: Codable {
|
|
230
|
+
let nativeBuild: String
|
|
231
|
+
let hashes: [String]
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private var builtinSeedURL: URL {
|
|
235
|
+
cacheDirectory.appendingPathComponent(DeltaAssembler.builtinSeedMarkerName, isDirectory: false)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private func readBuiltinSeed() -> BuiltinSeed? {
|
|
239
|
+
guard let data = try? Data(contentsOf: builtinSeedURL) else {
|
|
240
|
+
return nil
|
|
241
|
+
}
|
|
242
|
+
return try? JSONDecoder().decode(BuiltinSeed.self, from: data)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/// Hash the store-build web assets into the cache once per native build, so
|
|
246
|
+
/// the first OTA only downloads what changed relative to the binary.
|
|
247
|
+
/// Best-effort: failures only cost extra downloads.
|
|
248
|
+
func seedFromBuiltinIfNeeded(builtinDirectory: URL, nativeBuild: String) {
|
|
249
|
+
if let seed = readBuiltinSeed(), seed.nativeBuild == nativeBuild {
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
var isDirectory: ObjCBool = false
|
|
254
|
+
guard fileManager.fileExists(atPath: builtinDirectory.path, isDirectory: &isDirectory),
|
|
255
|
+
isDirectory.boolValue else {
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
var hashes: [String] = []
|
|
260
|
+
let enumerator = fileManager.enumerator(
|
|
261
|
+
at: builtinDirectory,
|
|
262
|
+
includingPropertiesForKeys: [.isRegularFileKey],
|
|
263
|
+
options: [.skipsHiddenFiles]
|
|
264
|
+
)
|
|
265
|
+
while let item = enumerator?.nextObject() as? URL {
|
|
266
|
+
guard let isRegular = try? item.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile,
|
|
267
|
+
isRegular == true else {
|
|
268
|
+
continue
|
|
269
|
+
}
|
|
270
|
+
guard let sha256 = try? HashUtils.sha256(fileURL: item) else {
|
|
271
|
+
continue
|
|
272
|
+
}
|
|
273
|
+
let destination = cachePath(for: sha256)
|
|
274
|
+
if !fileManager.fileExists(atPath: destination.path) {
|
|
275
|
+
let staging = cacheDirectory.appendingPathComponent(
|
|
276
|
+
".tmp-\(UUID().uuidString)",
|
|
277
|
+
isDirectory: false
|
|
278
|
+
)
|
|
279
|
+
if (try? fileManager.copyItem(at: item, to: staging)) != nil {
|
|
280
|
+
if (try? fileManager.moveItem(at: staging, to: destination)) == nil {
|
|
281
|
+
try? fileManager.removeItem(at: staging)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
hashes.append(sha256)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
let seed = BuiltinSeed(nativeBuild: nativeBuild, hashes: hashes)
|
|
289
|
+
if let data = try? JSONEncoder().encode(seed) {
|
|
290
|
+
try? data.write(to: builtinSeedURL, options: .atomic)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// MARK: - Eviction
|
|
295
|
+
|
|
296
|
+
/// Remove cache entries not referenced by any live bundle and not part of
|
|
297
|
+
/// the builtin seed. Best-effort.
|
|
298
|
+
func pruneCache(referencedHashes: Set<String>) {
|
|
299
|
+
var keep = Set(referencedHashes.map { $0.lowercased() })
|
|
300
|
+
if let seed = readBuiltinSeed() {
|
|
301
|
+
keep.formUnion(seed.hashes.map { $0.lowercased() })
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
guard let items = try? fileManager.contentsOfDirectory(atPath: cacheDirectory.path) else {
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
for item in items {
|
|
308
|
+
if item == DeltaAssembler.builtinSeedMarkerName || item.hasPrefix(".tmp-") {
|
|
309
|
+
continue
|
|
310
|
+
}
|
|
311
|
+
if !keep.contains(item.lowercased()) {
|
|
312
|
+
try? fileManager.removeItem(at: cacheDirectory.appendingPathComponent(item))
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
@@ -3,13 +3,35 @@ import Foundation
|
|
|
3
3
|
private let baseChannelKey = "__base__"
|
|
4
4
|
private let defaultRuntimeKey = "__default__"
|
|
5
5
|
|
|
6
|
+
struct ManifestEncryption {
|
|
7
|
+
let alg: String
|
|
8
|
+
let kid: String
|
|
9
|
+
let wrapNonce: String
|
|
10
|
+
let wrappedDek: String
|
|
11
|
+
let nonce: String
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
struct ManifestFileEntry {
|
|
15
|
+
let path: String
|
|
16
|
+
let sha256: String
|
|
17
|
+
let size: Int?
|
|
18
|
+
let url: String
|
|
19
|
+
}
|
|
20
|
+
|
|
6
21
|
struct LatestManifest {
|
|
7
22
|
let version: String
|
|
8
|
-
|
|
23
|
+
/// Bundle zip URL. Present for the zip strategy; nil for deltas.
|
|
24
|
+
let url: String?
|
|
25
|
+
/// Zip hash for the zip strategy; canonical filesHash for deltas.
|
|
9
26
|
let sha256: String
|
|
10
27
|
let size: Int
|
|
11
28
|
let runtimeVersion: String?
|
|
12
29
|
let releaseId: String
|
|
30
|
+
let strategy: String
|
|
31
|
+
let forceImmediate: Bool
|
|
32
|
+
let encryption: ManifestEncryption?
|
|
33
|
+
/// Per-file entries for the deltas strategy; nil for zip.
|
|
34
|
+
let files: [ManifestFileEntry]?
|
|
13
35
|
}
|
|
14
36
|
|
|
15
37
|
struct ManifestSignature {
|
|
@@ -94,7 +116,6 @@ enum ManifestClient {
|
|
|
94
116
|
guard
|
|
95
117
|
let object = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
96
118
|
let version = object["version"] as? String,
|
|
97
|
-
let downloadUrl = object["url"] as? String,
|
|
98
119
|
let sha256 = object["sha256"] as? String,
|
|
99
120
|
let size = object["size"] as? Int
|
|
100
121
|
else {
|
|
@@ -112,10 +133,23 @@ enum ManifestClient {
|
|
|
112
133
|
throw ManifestClientError.invalidResponse
|
|
113
134
|
}
|
|
114
135
|
|
|
115
|
-
|
|
116
|
-
|
|
136
|
+
let strategy = (object["strategy"] as? String)?
|
|
137
|
+
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
138
|
+
.nilIfEmpty ?? "zip"
|
|
139
|
+
let forceImmediate = object["forceImmediate"] as? Bool ?? false
|
|
140
|
+
let encryption = try parseEncryption(object["encryption"])
|
|
141
|
+
|
|
142
|
+
let downloadUrl = (object["url"] as? String)?.nilIfEmpty
|
|
143
|
+
var files: [ManifestFileEntry]?
|
|
144
|
+
|
|
145
|
+
if strategy == "deltas" {
|
|
146
|
+
files = try parseFiles(object["files"], allowInsecureUrls: allowInsecureUrls)
|
|
147
|
+
} else {
|
|
148
|
+
guard let downloadUrl, let dlURL = URL(string: downloadUrl) else {
|
|
149
|
+
throw ManifestClientError.invalidResponse
|
|
150
|
+
}
|
|
151
|
+
try requireHTTPS(url: dlURL, allowInsecure: allowInsecureUrls)
|
|
117
152
|
}
|
|
118
|
-
try requireHTTPS(url: dlURL, allowInsecure: allowInsecureUrls)
|
|
119
153
|
|
|
120
154
|
if manifestKeys.isEmpty {
|
|
121
155
|
print("[OtaKit] WARNING: No manifest signing keys configured — signature verification is disabled for this request.")
|
|
@@ -133,6 +167,9 @@ enum ManifestClient {
|
|
|
133
167
|
sha256: sha256,
|
|
134
168
|
size: size,
|
|
135
169
|
runtimeVersion: runtimeVersion,
|
|
170
|
+
strategy: strategy,
|
|
171
|
+
forceImmediate: forceImmediate,
|
|
172
|
+
encryption: encryption,
|
|
136
173
|
signature: signature,
|
|
137
174
|
trustedKeys: manifestKeys
|
|
138
175
|
)
|
|
@@ -144,7 +181,62 @@ enum ManifestClient {
|
|
|
144
181
|
sha256: sha256,
|
|
145
182
|
size: size,
|
|
146
183
|
runtimeVersion: runtimeVersion,
|
|
147
|
-
releaseId: releaseId
|
|
184
|
+
releaseId: releaseId,
|
|
185
|
+
strategy: strategy,
|
|
186
|
+
forceImmediate: forceImmediate,
|
|
187
|
+
encryption: encryption,
|
|
188
|
+
files: files
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private static func parseFiles(
|
|
193
|
+
_ rawValue: Any?,
|
|
194
|
+
allowInsecureUrls: Bool
|
|
195
|
+
) throws -> [ManifestFileEntry] {
|
|
196
|
+
guard let rawFiles = rawValue as? [[String: Any]], !rawFiles.isEmpty else {
|
|
197
|
+
throw ManifestClientError.invalidResponse
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
var entries: [ManifestFileEntry] = []
|
|
201
|
+
entries.reserveCapacity(rawFiles.count)
|
|
202
|
+
for rawFile in rawFiles {
|
|
203
|
+
guard let path = rawFile["path"] as? String,
|
|
204
|
+
let sha256 = rawFile["sha256"] as? String,
|
|
205
|
+
let fileUrl = rawFile["url"] as? String,
|
|
206
|
+
let parsedUrl = URL(string: fileUrl) else {
|
|
207
|
+
throw ManifestClientError.invalidResponse
|
|
208
|
+
}
|
|
209
|
+
try requireHTTPS(url: parsedUrl, allowInsecure: allowInsecureUrls)
|
|
210
|
+
entries.append(
|
|
211
|
+
ManifestFileEntry(
|
|
212
|
+
path: path,
|
|
213
|
+
sha256: sha256,
|
|
214
|
+
size: rawFile["size"] as? Int,
|
|
215
|
+
url: fileUrl
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
return entries
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private static func parseEncryption(_ rawValue: Any?) throws -> ManifestEncryption? {
|
|
223
|
+
guard let rawValue, !(rawValue is NSNull) else {
|
|
224
|
+
return nil
|
|
225
|
+
}
|
|
226
|
+
guard let object = rawValue as? [String: Any],
|
|
227
|
+
let alg = object["alg"] as? String,
|
|
228
|
+
let kid = object["kid"] as? String,
|
|
229
|
+
let wrapNonce = object["wrapNonce"] as? String,
|
|
230
|
+
let wrappedDek = object["wrappedDek"] as? String,
|
|
231
|
+
let nonce = object["nonce"] as? String else {
|
|
232
|
+
throw ManifestClientError.invalidResponse
|
|
233
|
+
}
|
|
234
|
+
return ManifestEncryption(
|
|
235
|
+
alg: alg,
|
|
236
|
+
kid: kid,
|
|
237
|
+
wrapNonce: wrapNonce,
|
|
238
|
+
wrappedDek: wrappedDek,
|
|
239
|
+
nonce: nonce
|
|
148
240
|
)
|
|
149
241
|
}
|
|
150
242
|
|
|
@@ -31,6 +31,9 @@ enum ManifestVerifier {
|
|
|
31
31
|
sha256: String,
|
|
32
32
|
size: Int,
|
|
33
33
|
runtimeVersion: String?,
|
|
34
|
+
strategy: String,
|
|
35
|
+
forceImmediate: Bool,
|
|
36
|
+
encryption: ManifestEncryption?,
|
|
34
37
|
signature: ManifestSignature,
|
|
35
38
|
trustedKeys: [ManifestKey]
|
|
36
39
|
) throws {
|
|
@@ -41,6 +44,9 @@ enum ManifestVerifier {
|
|
|
41
44
|
sha256: sha256,
|
|
42
45
|
size: size,
|
|
43
46
|
runtimeVersion: runtimeVersion,
|
|
47
|
+
strategy: strategy,
|
|
48
|
+
forceImmediate: forceImmediate,
|
|
49
|
+
encryption: encryption,
|
|
44
50
|
kid: signature.kid,
|
|
45
51
|
iat: signature.iat,
|
|
46
52
|
exp: signature.exp
|
|
@@ -78,6 +84,23 @@ enum ManifestVerifier {
|
|
|
78
84
|
}
|
|
79
85
|
}
|
|
80
86
|
|
|
87
|
+
/// Encode the encryption block for the canonical payload.
|
|
88
|
+
/// Must match the server's `encodeEncryptionForPayload` exactly.
|
|
89
|
+
private static func encodeEncryptionForPayload(_ encryption: ManifestEncryption?) -> String {
|
|
90
|
+
guard let encryption else {
|
|
91
|
+
return "null"
|
|
92
|
+
}
|
|
93
|
+
return [
|
|
94
|
+
encryption.alg,
|
|
95
|
+
encryption.kid,
|
|
96
|
+
encryption.wrapNonce,
|
|
97
|
+
encryption.wrappedDek,
|
|
98
|
+
encryption.nonce,
|
|
99
|
+
].joined(separator: "|")
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/// Canonical payload v2 — must match the server's `buildCanonicalPayload`
|
|
103
|
+
/// (console/lib/manifest-signing.ts) and the Android mirror byte-for-byte.
|
|
81
104
|
private static func buildCanonicalPayload(
|
|
82
105
|
appId: String,
|
|
83
106
|
channel: String?,
|
|
@@ -85,6 +108,9 @@ enum ManifestVerifier {
|
|
|
85
108
|
sha256: String,
|
|
86
109
|
size: Int,
|
|
87
110
|
runtimeVersion: String?,
|
|
111
|
+
strategy: String,
|
|
112
|
+
forceImmediate: Bool,
|
|
113
|
+
encryption: ManifestEncryption?,
|
|
88
114
|
kid: String,
|
|
89
115
|
iat: Int,
|
|
90
116
|
exp: Int
|
|
@@ -97,6 +123,9 @@ enum ManifestVerifier {
|
|
|
97
123
|
"sha256:\(sha256)",
|
|
98
124
|
"size:\(size)",
|
|
99
125
|
"runtimeVersion:\(runtimeVersion ?? "null")",
|
|
126
|
+
"strategy:\(strategy)",
|
|
127
|
+
"forceImmediate:\(forceImmediate ? "true" : "false")",
|
|
128
|
+
"encryption:\(encodeEncryptionForPayload(encryption))",
|
|
100
129
|
"kid:\(kid)",
|
|
101
130
|
"iat:\(iat)",
|
|
102
131
|
"exp:\(exp)",
|