Skip to content

Commit 90606f3

Browse files
authored
Verify PGP signatures of toolchains before installing (#94)
1 parent 92339d6 commit 90606f3

15 files changed

+185
-27
lines changed

Sources/LinuxPlatform/Linux.swift

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,49 @@ public struct Linux: Platform {
2525
true
2626
}
2727

28+
private static let skipVerificationMessage: String = "To skip signature verification, specify the --no-verify flag."
29+
30+
public func verifySystemPrerequisitesForInstall(requireSignatureValidation: Bool) throws {
31+
// The only prerequisite at the moment is that gpg is installed and the Swift project's keys have been imported.
32+
guard requireSignatureValidation else {
33+
return
34+
}
35+
36+
guard (try? self.runProgram("gpg", "--version", quiet: true)) != nil else {
37+
throw Error(message: "gpg not installed, cannot perform signature verification. To set up gpg for " +
38+
"toolchain signature validation, follow the instructions at " +
39+
"https://www.swift.org/install/linux/#installation-via-tarball. " + Self.skipVerificationMessage)
40+
}
41+
42+
let foundKeys = (try? self.runProgram(
43+
"gpg",
44+
"--list-keys",
45+
46+
47+
quiet: true
48+
)) != nil
49+
guard foundKeys else {
50+
throw Error(message: "Swift PGP keys not imported, cannot perform signature verification. " +
51+
"To enable verification, import the keys with the following command: " +
52+
"'wget -q -O - https://swift.org/keys/all-keys.asc | gpg --import -' " +
53+
Self.skipVerificationMessage)
54+
}
55+
56+
SwiftlyCore.print("Refreshing Swift PGP keys...")
57+
do {
58+
try self.runProgram(
59+
"gpg",
60+
"--quiet",
61+
"--keyserver",
62+
"hkp://keyserver.ubuntu.com",
63+
"--refresh-keys",
64+
"Swift"
65+
)
66+
} catch {
67+
throw Error(message: "Failed to refresh PGP keys: \(error)")
68+
}
69+
}
70+
2871
public func install(from tmpFile: URL, version: ToolchainVersion) throws {
2972
guard tmpFile.fileExists() else {
3073
throw Error(message: "\(tmpFile) doesn't exist")
@@ -141,5 +184,44 @@ public struct Linux: Platform {
141184
FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID())")
142185
}
143186

187+
public func verifySignature(httpClient: SwiftlyHTTPClient, archiveDownloadURL: URL, archive: URL) async throws {
188+
SwiftlyCore.print("Downloading toolchain signature...")
189+
let sigFile = self.getTempFilePath()
190+
FileManager.default.createFile(atPath: sigFile.path, contents: nil)
191+
defer {
192+
try? FileManager.default.removeItem(at: sigFile)
193+
}
194+
195+
try await httpClient.downloadFile(
196+
url: archiveDownloadURL.appendingPathExtension("sig"),
197+
to: sigFile
198+
)
199+
200+
SwiftlyCore.print("Verifying toolchain signature...")
201+
do {
202+
try self.runProgram("gpg", "--verify", sigFile.path, archive.path)
203+
} catch {
204+
throw Error(message: "Toolchain signature verification failed: \(error)")
205+
}
206+
}
207+
208+
private func runProgram(_ args: String..., quiet: Bool = false) throws {
209+
let process = Process()
210+
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
211+
process.arguments = args
212+
213+
if quiet {
214+
process.standardOutput = nil
215+
process.standardError = nil
216+
}
217+
218+
try process.run()
219+
process.waitUntilExit()
220+
221+
guard process.terminationStatus == 0 else {
222+
throw Error(message: "\(args.first!) exited with non-zero status: \(process.terminationStatus)")
223+
}
224+
}
225+
144226
public static let currentPlatform: any Platform = Linux()
145227
}

Sources/Swiftly/Install.swift

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,30 +56,44 @@ struct Install: SwiftlyCommand {
5656
))
5757
var token: String?
5858

59+
@Flag(inversion: .prefixedNo, help: "Verify the toolchain's PGP signature before proceeding with installation.")
60+
var verify = true
61+
5962
public var httpClient = SwiftlyHTTPClient()
6063

6164
private enum CodingKeys: String, CodingKey {
62-
case version, token, use
65+
case version, token, use, verify
6366
}
6467

6568
mutating func run() async throws {
6669
let selector = try ToolchainSelector(parsing: self.version)
6770
self.httpClient.githubToken = self.token
6871
let toolchainVersion = try await self.resolve(selector: selector)
6972
var config = try Config.load()
70-
try await Self.execute(version: toolchainVersion, &config, self.httpClient, useInstalledToolchain: self.use)
73+
try await Self.execute(
74+
version: toolchainVersion,
75+
&config,
76+
self.httpClient,
77+
useInstalledToolchain: self.use,
78+
verifySignature: self.verify
79+
)
7180
}
7281

7382
internal static func execute(
7483
version: ToolchainVersion,
7584
_ config: inout Config,
7685
_ httpClient: SwiftlyHTTPClient,
77-
useInstalledToolchain: Bool
86+
useInstalledToolchain: Bool,
87+
verifySignature: Bool
7888
) async throws {
7989
guard !config.installedToolchains.contains(version) else {
8090
SwiftlyCore.print("\(version) is already installed, exiting.")
8191
return
8292
}
93+
94+
// Ensure the system is set up correctly to install a toolchain before downloading it.
95+
try Swiftly.currentPlatform.verifySystemPrerequisitesForInstall(requireSignatureValidation: verifySignature)
96+
8397
SwiftlyCore.print("Installing \(version)")
8498

8599
let tmpFile = Swiftly.currentPlatform.getTempFilePath()
@@ -167,9 +181,16 @@ struct Install: SwiftlyCommand {
167181
animation.complete(success: false)
168182
throw error
169183
}
170-
171184
animation.complete(success: true)
172185

186+
if verifySignature {
187+
try await Swiftly.currentPlatform.verifySignature(
188+
httpClient: httpClient,
189+
archiveDownloadURL: url,
190+
archive: tmpFile
191+
)
192+
}
193+
173194
try Swiftly.currentPlatform.install(from: tmpFile, version: version)
174195

175196
config.installedToolchains.insert(version)

Sources/Swiftly/Update.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,13 @@ struct Update: SwiftlyCommand {
6464
)
6565
var assumeYes: Bool = false
6666

67+
@Flag(inversion: .prefixedNo, help: "Verify the toolchain's PGP signature before proceeding with installation.")
68+
var verify = true
69+
6770
public var httpClient = SwiftlyHTTPClient()
6871

6972
private enum CodingKeys: String, CodingKey {
70-
case toolchain, assumeYes
73+
case toolchain, assumeYes, verify
7174
}
7275

7376
public mutating func run() async throws {
@@ -104,7 +107,8 @@ struct Update: SwiftlyCommand {
104107
version: newToolchain,
105108
&config,
106109
self.httpClient,
107-
useInstalledToolchain: config.inUse == parameters.oldToolchain
110+
useInstalledToolchain: config.inUse == parameters.oldToolchain,
111+
verifySignature: self.verify
108112
)
109113

110114
try await Uninstall.execute(parameters.oldToolchain, &config)

Sources/SwiftlyCore/HTTPClient.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,11 @@ public struct SwiftlyHTTPClient {
137137
public let url: String
138138
}
139139

140-
public func downloadFile(url: URL, to destination: URL, reportProgress: @escaping (DownloadProgress) -> Void) async throws {
140+
public func downloadFile(
141+
url: URL,
142+
to destination: URL,
143+
reportProgress: ((DownloadProgress) -> Void)? = nil
144+
) async throws {
141145
let fileHandle = try FileHandle(forWritingTo: destination)
142146
defer {
143147
try? fileHandle.close()
@@ -168,7 +172,7 @@ public struct SwiftlyHTTPClient {
168172
}
169173

170174
let now = Date()
171-
if lastUpdate.distance(to: now) > 0.25 || receivedBytes == expectedBytes {
175+
if let reportProgress, lastUpdate.distance(to: now) > 0.25 || receivedBytes == expectedBytes {
172176
lastUpdate = now
173177
reportProgress(SwiftlyHTTPClient.DownloadProgress(
174178
receivedBytes: receivedBytes,

Sources/SwiftlyCore/Platform.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,17 @@ public protocol Platform {
3939
/// Get a path pointing to a unique, temporary file.
4040
/// This does not need to actually create the file.
4141
func getTempFilePath() -> URL
42+
43+
/// Verifies that the system meets the requirements needed to install a toolchain.
44+
/// `requireSignatureValidation` specifies whether the system's support for toolchain signature validation should be verified.
45+
///
46+
/// Throws if system does not meet the requirements.
47+
func verifySystemPrerequisitesForInstall(requireSignatureValidation: Bool) throws
48+
49+
/// Downloads the signature file associated with the archive and verifies it matches the downloaded archive.
50+
/// Throws an error if the signature does not match.
51+
/// On Linux, signature verification will be skipped if gpg is not installed.
52+
func verifySignature(httpClient: SwiftlyHTTPClient, archiveDownloadURL: URL, archive: URL) async throws
4253
}
4354

4455
extension Platform {

Tests/SwiftlyTests/SwiftlyTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ class SwiftlyTests: XCTestCase {
174174
///
175175
/// When executed, the mocked executables will simply print the toolchain version and return.
176176
func installMockedToolchain(selector: String, args: [String] = [], executables: [String]? = nil) async throws {
177-
var install = try self.parseCommand(Install.self, ["install", "\(selector)"] + args)
177+
var install = try self.parseCommand(Install.self, ["install", "\(selector)", "--no-verify"] + args)
178178
install.httpClient = SwiftlyHTTPClient(executor: MockToolchainDownloader(executables: executables))
179179
try await install.run()
180180
}

Tests/SwiftlyTests/UpdateTests.swift

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ final class UpdateTests: SwiftlyTests {
1313

1414
let beforeUpdateConfig = try Config.load()
1515

16-
var update = try self.parseCommand(Update.self, ["update", "latest"])
16+
var update = try self.parseCommand(Update.self, ["update", "latest", "--no-verify"])
1717
update.httpClient = self.mockHttpClient
1818
try await update.run()
1919

@@ -28,7 +28,7 @@ final class UpdateTests: SwiftlyTests {
2828
/// Verify that attempting to update when no toolchains are installed has no effect.
2929
func testUpdateLatestWithNoToolchains() async throws {
3030
try await self.withTestHome {
31-
var update = try self.parseCommand(Update.self, ["update", "latest"])
31+
var update = try self.parseCommand(Update.self, ["update", "latest", "--no-verify"])
3232
update.httpClient = self.mockHttpClient
3333
try await update.run()
3434

@@ -43,7 +43,7 @@ final class UpdateTests: SwiftlyTests {
4343
func testUpdateLatestToLatest() async throws {
4444
try await self.withTestHome {
4545
try await self.installMockedToolchain(selector: .stable(major: 5, minor: 0, patch: 0))
46-
var update = try self.parseCommand(Update.self, ["update", "-y", "latest"])
46+
var update = try self.parseCommand(Update.self, ["update", "-y", "latest", "--no-verify"])
4747
update.httpClient = self.mockHttpClient
4848
try await update.run()
4949

@@ -63,7 +63,7 @@ final class UpdateTests: SwiftlyTests {
6363
func testUpdateToLatestMinor() async throws {
6464
try await self.withTestHome {
6565
try await self.installMockedToolchain(selector: .stable(major: 5, minor: 0, patch: 0))
66-
var update = try self.parseCommand(Update.self, ["update", "-y", "5"])
66+
var update = try self.parseCommand(Update.self, ["update", "-y", "5", "--no-verify"])
6767
update.httpClient = self.mockHttpClient
6868
try await update.run()
6969

@@ -85,7 +85,7 @@ final class UpdateTests: SwiftlyTests {
8585
try await self.withTestHome {
8686
try await self.installMockedToolchain(selector: "5.0.0")
8787

88-
var update = try self.parseCommand(Update.self, ["update", "-y", "5.0.0"])
88+
var update = try self.parseCommand(Update.self, ["update", "-y", "5.0.0", "--no-verify"])
8989
update.httpClient = self.mockHttpClient
9090
try await update.run()
9191

@@ -109,7 +109,7 @@ final class UpdateTests: SwiftlyTests {
109109
try await self.withTestHome {
110110
try await self.installMockedToolchain(selector: "5.0.0")
111111

112-
var update = try self.parseCommand(Update.self, ["update", "-y"])
112+
var update = try self.parseCommand(Update.self, ["update", "-y", "--no-verify"])
113113
update.httpClient = self.mockHttpClient
114114
try await update.run()
115115

@@ -141,7 +141,9 @@ final class UpdateTests: SwiftlyTests {
141141
let date = "2023-09-19"
142142
try await self.installMockedToolchain(selector: .snapshot(branch: branch, date: date))
143143

144-
var update = try self.parseCommand(Update.self, ["update", "-y", "\(branch.name)-snapshot"])
144+
var update = try self.parseCommand(
145+
Update.self, ["update", "-y", "\(branch.name)-snapshot", "--no-verify"]
146+
)
145147
update.httpClient = self.mockHttpClient
146148
try await update.run()
147149

@@ -165,7 +167,7 @@ final class UpdateTests: SwiftlyTests {
165167
try await self.installMockedToolchain(selector: "5.0.1")
166168
try await self.installMockedToolchain(selector: "5.0.0")
167169

168-
var update = try self.parseCommand(Update.self, ["update", "-y", "5.0"])
170+
var update = try self.parseCommand(Update.self, ["update", "-y", "5.0", "--no-verify"])
169171
update.httpClient = self.mockHttpClient
170172
try await update.run()
171173

@@ -194,7 +196,9 @@ final class UpdateTests: SwiftlyTests {
194196
try await self.installMockedToolchain(selector: .snapshot(branch: branch, date: "2023-09-19"))
195197
try await self.installMockedToolchain(selector: .snapshot(branch: branch, date: "2023-09-16"))
196198

197-
var update = try self.parseCommand(Update.self, ["update", "-y", "\(branch.name)-snapshot"])
199+
var update = try self.parseCommand(
200+
Update.self, ["update", "-y", "\(branch.name)-snapshot", "--no-verify"]
201+
)
198202
update.httpClient = self.mockHttpClient
199203
try await update.run()
200204

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
ARG base_image=amazonlinux:2
22
FROM $base_image
33

4-
RUN yum install -y curl util-linux
4+
RUN yum install -y curl util-linux gpg
55
RUN echo 'export PATH="$HOME/.local/bin:$PATH"' >> $HOME/.profile

docker/install-test-ubi9.dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
ARG base_image=redhat/ubi9:latest
22
FROM $base_image
33

4-
RUN yum install --allowerasing -y curl gcc-c++
4+
RUN yum install --allowerasing -y curl gcc-c++ gpg
55
RUN echo 'export PATH="$HOME/.local/bin:$PATH"' >> $HOME/.profile

docker/install-test.dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,5 @@ ENV LANG en_US.UTF-8
88
ENV LANGUAGE en_US.UTF-8
99

1010
# dependencies
11-
RUN apt-get update --fix-missing && apt-get install -y curl
11+
RUN apt-get update --fix-missing && apt-get install -y curl gpg
1212
RUN echo 'export PATH="$HOME/.local/bin:$PATH"' >> $HOME/.profile

0 commit comments

Comments
 (0)