-
Notifications
You must be signed in to change notification settings - Fork 2
/
Copy pathDownload.swift
197 lines (170 loc) · 7.26 KB
/
Download.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
import CryptoKit
import Foundation
public enum ValidationError: Error {
case fileNotFound
case unableToCreateStaticCode
case invalidSignature
case unableToRetrieveInfo
case invalidIdentifier(identifier: String?)
case invalidTeamIdentifier(identifier: String?)
case missingInfoPList
case invalidVersion(version: String?)
case belowMinimumCoderVersion
public var description: String {
switch self {
case .fileNotFound:
"The file does not exist."
case .unableToCreateStaticCode:
"Unable to create a static code object."
case .invalidSignature:
"The file's signature is invalid."
case .unableToRetrieveInfo:
"Unable to retrieve signing information."
case let .invalidIdentifier(identifier):
"Invalid identifier: \(identifier ?? "unknown")."
case let .invalidVersion(version):
"Invalid runtime version: \(version ?? "unknown")."
case let .invalidTeamIdentifier(identifier):
"Invalid team identifier: \(identifier ?? "unknown")."
case .missingInfoPList:
"Info.plist is not embedded within the dylib."
case .belowMinimumCoderVersion:
"""
The Coder deployment must be version \(SignatureValidator.minimumCoderVersion)
or higher to use Coder Desktop.
"""
}
}
public var localizedDescription: String { description }
}
public class SignatureValidator {
// Whilst older dylibs exist, this app assumes v2.20 or later.
public static let minimumCoderVersion = "2.20.0"
private static let expectedName = "CoderVPN"
private static let expectedIdentifier = "com.coder.Coder-Desktop.VPN.dylib"
private static let expectedTeamIdentifier = "4399GN35BJ"
private static let infoIdentifierKey = "CFBundleIdentifier"
private static let infoNameKey = "CFBundleName"
private static let infoShortVersionKey = "CFBundleShortVersionString"
private static let signInfoFlags: SecCSFlags = .init(rawValue: kSecCSSigningInformation)
// `expectedVersion` must be of the form `[0-9]+.[0-9]+.[0-9]+`
public static func validate(path: URL, expectedVersion: String) throws(ValidationError) {
guard FileManager.default.fileExists(atPath: path.path) else {
throw .fileNotFound
}
var staticCode: SecStaticCode?
let status = SecStaticCodeCreateWithPath(path as CFURL, SecCSFlags(), &staticCode)
guard status == errSecSuccess, let code = staticCode else {
throw .unableToCreateStaticCode
}
let validateStatus = SecStaticCodeCheckValidity(code, SecCSFlags(), nil)
guard validateStatus == errSecSuccess else {
throw .invalidSignature
}
var information: CFDictionary?
let infoStatus = SecCodeCopySigningInformation(code, signInfoFlags, &information)
guard infoStatus == errSecSuccess, let info = information as? [String: Any] else {
throw .unableToRetrieveInfo
}
guard let identifier = info[kSecCodeInfoIdentifier as String] as? String,
identifier == expectedIdentifier
else {
throw .invalidIdentifier(identifier: info[kSecCodeInfoIdentifier as String] as? String)
}
guard let teamIdentifier = info[kSecCodeInfoTeamIdentifier as String] as? String,
teamIdentifier == expectedTeamIdentifier
else {
throw .invalidTeamIdentifier(
identifier: info[kSecCodeInfoTeamIdentifier as String] as? String
)
}
guard let infoPlist = info[kSecCodeInfoPList as String] as? [String: AnyObject] else {
throw .missingInfoPList
}
try validateInfo(infoPlist: infoPlist, expectedVersion: expectedVersion)
}
private static func validateInfo(infoPlist: [String: AnyObject], expectedVersion: String) throws(ValidationError) {
guard let plistIdent = infoPlist[infoIdentifierKey] as? String, plistIdent == expectedIdentifier else {
throw .invalidIdentifier(identifier: infoPlist[infoIdentifierKey] as? String)
}
guard let plistName = infoPlist[infoNameKey] as? String, plistName == expectedName else {
throw .invalidIdentifier(identifier: infoPlist[infoNameKey] as? String)
}
// Downloaded dylib must match the version of the server
guard let dylibVersion = infoPlist[infoShortVersionKey] as? String,
expectedVersion == dylibVersion
else {
throw .invalidVersion(version: infoPlist[infoShortVersionKey] as? String)
}
// Downloaded dylib must be at least the minimum Coder server version
guard let dylibVersion = infoPlist[infoShortVersionKey] as? String,
// x.compare(y) is .orderedDescending if x > y
minimumCoderVersion.compare(dylibVersion, options: .numeric) != .orderedDescending
else {
throw .belowMinimumCoderVersion
}
}
}
public func download(src: URL, dest: URL, urlSession: URLSession) async throws(DownloadError) {
var req = URLRequest(url: src)
if FileManager.default.fileExists(atPath: dest.path) {
if let existingFileData = try? Data(contentsOf: dest, options: .mappedIfSafe) {
req.setValue(etag(data: existingFileData), forHTTPHeaderField: "If-None-Match")
}
}
// TODO: Add Content-Length headers to coderd, add download progress delegate
let tempURL: URL
let response: URLResponse
do {
(tempURL, response) = try await urlSession.download(for: req)
} catch {
throw .networkError(error, url: src.absoluteString)
}
defer {
if FileManager.default.fileExists(atPath: tempURL.path) {
try? FileManager.default.removeItem(at: tempURL)
}
}
guard let httpResponse = response as? HTTPURLResponse else {
throw .invalidResponse
}
guard httpResponse.statusCode != 304 else {
// We already have the latest dylib downloaded on disk
return
}
guard httpResponse.statusCode == 200 else {
throw .unexpectedStatusCode(httpResponse.statusCode)
}
do {
if FileManager.default.fileExists(atPath: dest.path) {
try FileManager.default.removeItem(at: dest)
}
try FileManager.default.moveItem(at: tempURL, to: dest)
} catch {
throw .fileOpError(error)
}
}
func etag(data: Data) -> String {
let sha1Hash = Insecure.SHA1.hash(data: data)
let etag = sha1Hash.map { String(format: "%02x", $0) }.joined()
return "\"\(etag)\""
}
public enum DownloadError: Error {
case unexpectedStatusCode(Int)
case invalidResponse
case networkError(any Error, url: String)
case fileOpError(any Error)
public var description: String {
switch self {
case let .unexpectedStatusCode(code):
"Unexpected HTTP status code: \(code)"
case let .networkError(error, url):
"Network error: \(url) - \(error.localizedDescription)"
case let .fileOpError(error):
"File operation error: \(error.localizedDescription)"
case .invalidResponse:
"Received non-HTTP response"
}
}
public var localizedDescription: String { description }
}