Skip to content

Verify signatures of toolchains before installing #94

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Apr 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions Sources/LinuxPlatform/Linux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,49 @@ public struct Linux: Platform {
true
}

private static let skipVerificationMessage: String = "To skip signature verification, specify the --no-verify flag."

public func verifySystemPrerequisitesForInstall(requireSignatureValidation: Bool) throws {
// The only prerequisite at the moment is that gpg is installed and the Swift project's keys have been imported.
guard requireSignatureValidation else {
return
}

guard (try? self.runProgram("gpg", "--version", quiet: true)) != nil else {
throw Error(message: "gpg not installed, cannot perform signature verification. To set up gpg for " +
"toolchain signature validation, follow the instructions at " +
"https://www.swift.org/install/linux/#installation-via-tarball. " + Self.skipVerificationMessage)
}

let foundKeys = (try? self.runProgram(
"gpg",
"--list-keys",
"swift-infrastructure@forums.swift.org",
"swift-infrastructure@swift.org",
quiet: true
)) != nil
guard foundKeys else {
throw Error(message: "Swift PGP keys not imported, cannot perform signature verification. " +
"To enable verification, import the keys with the following command: " +
"'wget -q -O - https://swift.org/keys/all-keys.asc | gpg --import -' " +
Self.skipVerificationMessage)
}

SwiftlyCore.print("Refreshing Swift PGP keys...")
do {
try self.runProgram(
"gpg",
"--quiet",
"--keyserver",
"hkp://keyserver.ubuntu.com",
"--refresh-keys",
"Swift"
)
} catch {
throw Error(message: "Failed to refresh PGP keys: \(error)")
}
}

public func install(from tmpFile: URL, version: ToolchainVersion) throws {
guard tmpFile.fileExists() else {
throw Error(message: "\(tmpFile) doesn't exist")
Expand Down Expand Up @@ -141,5 +184,44 @@ public struct Linux: Platform {
FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID())")
}

public func verifySignature(httpClient: SwiftlyHTTPClient, archiveDownloadURL: URL, archive: URL) async throws {
SwiftlyCore.print("Downloading toolchain signature...")
let sigFile = self.getTempFilePath()
FileManager.default.createFile(atPath: sigFile.path, contents: nil)
defer {
try? FileManager.default.removeItem(at: sigFile)
}

try await httpClient.downloadFile(
url: archiveDownloadURL.appendingPathExtension("sig"),
to: sigFile
)

SwiftlyCore.print("Verifying toolchain signature...")
do {
try self.runProgram("gpg", "--verify", sigFile.path, archive.path)
} catch {
throw Error(message: "Toolchain signature verification failed: \(error)")
}
}

private func runProgram(_ args: String..., quiet: Bool = false) throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = args

if quiet {
process.standardOutput = nil
process.standardError = nil
}

try process.run()
process.waitUntilExit()

guard process.terminationStatus == 0 else {
throw Error(message: "\(args.first!) exited with non-zero status: \(process.terminationStatus)")
}
}

public static let currentPlatform: any Platform = Linux()
}
29 changes: 25 additions & 4 deletions Sources/Swiftly/Install.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,30 +56,44 @@ struct Install: SwiftlyCommand {
))
var token: String?

@Flag(inversion: .prefixedNo, help: "Verify the toolchain's PGP signature before proceeding with installation.")
var verify = true

public var httpClient = SwiftlyHTTPClient()

private enum CodingKeys: String, CodingKey {
case version, token, use
case version, token, use, verify
}

mutating func run() async throws {
let selector = try ToolchainSelector(parsing: self.version)
self.httpClient.githubToken = self.token
let toolchainVersion = try await self.resolve(selector: selector)
var config = try Config.load()
try await Self.execute(version: toolchainVersion, &config, self.httpClient, useInstalledToolchain: self.use)
try await Self.execute(
version: toolchainVersion,
&config,
self.httpClient,
useInstalledToolchain: self.use,
verifySignature: self.verify
)
}

internal static func execute(
version: ToolchainVersion,
_ config: inout Config,
_ httpClient: SwiftlyHTTPClient,
useInstalledToolchain: Bool
useInstalledToolchain: Bool,
verifySignature: Bool
) async throws {
guard !config.installedToolchains.contains(version) else {
SwiftlyCore.print("\(version) is already installed, exiting.")
return
}

// Ensure the system is set up correctly to install a toolchain before downloading it.
try Swiftly.currentPlatform.verifySystemPrerequisitesForInstall(requireSignatureValidation: verifySignature)

SwiftlyCore.print("Installing \(version)")

let tmpFile = Swiftly.currentPlatform.getTempFilePath()
Expand Down Expand Up @@ -167,9 +181,16 @@ struct Install: SwiftlyCommand {
animation.complete(success: false)
throw error
}

animation.complete(success: true)

if verifySignature {
try await Swiftly.currentPlatform.verifySignature(
httpClient: httpClient,
archiveDownloadURL: url,
archive: tmpFile
)
}

try Swiftly.currentPlatform.install(from: tmpFile, version: version)

config.installedToolchains.insert(version)
Expand Down
8 changes: 6 additions & 2 deletions Sources/Swiftly/Update.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,13 @@ struct Update: SwiftlyCommand {
)
var assumeYes: Bool = false

@Flag(inversion: .prefixedNo, help: "Verify the toolchain's PGP signature before proceeding with installation.")
var verify = true

public var httpClient = SwiftlyHTTPClient()

private enum CodingKeys: String, CodingKey {
case toolchain, assumeYes
case toolchain, assumeYes, verify
}

public mutating func run() async throws {
Expand Down Expand Up @@ -104,7 +107,8 @@ struct Update: SwiftlyCommand {
version: newToolchain,
&config,
self.httpClient,
useInstalledToolchain: config.inUse == parameters.oldToolchain
useInstalledToolchain: config.inUse == parameters.oldToolchain,
verifySignature: self.verify
)

try await Uninstall.execute(parameters.oldToolchain, &config)
Expand Down
8 changes: 6 additions & 2 deletions Sources/SwiftlyCore/HTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,11 @@ public struct SwiftlyHTTPClient {
public let url: String
}

public func downloadFile(url: URL, to destination: URL, reportProgress: @escaping (DownloadProgress) -> Void) async throws {
public func downloadFile(
url: URL,
to destination: URL,
reportProgress: ((DownloadProgress) -> Void)? = nil
) async throws {
let fileHandle = try FileHandle(forWritingTo: destination)
defer {
try? fileHandle.close()
Expand Down Expand Up @@ -168,7 +172,7 @@ public struct SwiftlyHTTPClient {
}

let now = Date()
if lastUpdate.distance(to: now) > 0.25 || receivedBytes == expectedBytes {
if let reportProgress, lastUpdate.distance(to: now) > 0.25 || receivedBytes == expectedBytes {
lastUpdate = now
reportProgress(SwiftlyHTTPClient.DownloadProgress(
receivedBytes: receivedBytes,
Expand Down
11 changes: 11 additions & 0 deletions Sources/SwiftlyCore/Platform.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ public protocol Platform {
/// Get a path pointing to a unique, temporary file.
/// This does not need to actually create the file.
func getTempFilePath() -> URL

/// Verifies that the system meets the requirements needed to install a toolchain.
/// `requireSignatureValidation` specifies whether the system's support for toolchain signature validation should be verified.
///
/// Throws if system does not meet the requirements.
func verifySystemPrerequisitesForInstall(requireSignatureValidation: Bool) throws

/// Downloads the signature file associated with the archive and verifies it matches the downloaded archive.
/// Throws an error if the signature does not match.
/// On Linux, signature verification will be skipped if gpg is not installed.
func verifySignature(httpClient: SwiftlyHTTPClient, archiveDownloadURL: URL, archive: URL) async throws
}

extension Platform {
Expand Down
2 changes: 1 addition & 1 deletion Tests/SwiftlyTests/SwiftlyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ class SwiftlyTests: XCTestCase {
///
/// When executed, the mocked executables will simply print the toolchain version and return.
func installMockedToolchain(selector: String, args: [String] = [], executables: [String]? = nil) async throws {
var install = try self.parseCommand(Install.self, ["install", "\(selector)"] + args)
var install = try self.parseCommand(Install.self, ["install", "\(selector)", "--no-verify"] + args)
install.httpClient = SwiftlyHTTPClient(executor: MockToolchainDownloader(executables: executables))
try await install.run()
}
Expand Down
22 changes: 13 additions & 9 deletions Tests/SwiftlyTests/UpdateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ final class UpdateTests: SwiftlyTests {

let beforeUpdateConfig = try Config.load()

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

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

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

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

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

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

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

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

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

var update = try self.parseCommand(Update.self, ["update", "-y", "\(branch.name)-snapshot"])
var update = try self.parseCommand(
Update.self, ["update", "-y", "\(branch.name)-snapshot", "--no-verify"]
)
update.httpClient = self.mockHttpClient
try await update.run()

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

var update = try self.parseCommand(Update.self, ["update", "-y", "5.0"])
var update = try self.parseCommand(Update.self, ["update", "-y", "5.0", "--no-verify"])
update.httpClient = self.mockHttpClient
try await update.run()

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

var update = try self.parseCommand(Update.self, ["update", "-y", "\(branch.name)-snapshot"])
var update = try self.parseCommand(
Update.self, ["update", "-y", "\(branch.name)-snapshot", "--no-verify"]
)
update.httpClient = self.mockHttpClient
try await update.run()

Expand Down
2 changes: 1 addition & 1 deletion docker/install-test-amazonlinux2.dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
ARG base_image=amazonlinux:2
FROM $base_image

RUN yum install -y curl util-linux
RUN yum install -y curl util-linux gpg
RUN echo 'export PATH="$HOME/.local/bin:$PATH"' >> $HOME/.profile
2 changes: 1 addition & 1 deletion docker/install-test-ubi9.dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
ARG base_image=redhat/ubi9:latest
FROM $base_image

RUN yum install --allowerasing -y curl gcc-c++
RUN yum install --allowerasing -y curl gcc-c++ gpg
RUN echo 'export PATH="$HOME/.local/bin:$PATH"' >> $HOME/.profile
2 changes: 1 addition & 1 deletion docker/install-test.dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ ENV LANG en_US.UTF-8
ENV LANGUAGE en_US.UTF-8

# dependencies
RUN apt-get update --fix-missing && apt-get install -y curl
RUN apt-get update --fix-missing && apt-get install -y curl gpg
RUN echo 'export PATH="$HOME/.local/bin:$PATH"' >> $HOME/.profile
5 changes: 4 additions & 1 deletion docker/test-amazonlinux2.dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ RUN yum install -y \
curl \
gcc \
gcc-c++ \
make
make \
gpg
COPY ./scripts/install-libarchive.sh /
RUN /install-libarchive.sh

RUN curl -L https://swift.org/keys/all-keys.asc | gpg --import

# tools
RUN mkdir -p $HOME/.tools
RUN echo 'export PATH="$HOME/.tools:$PATH"' >> $HOME/.profile
5 changes: 4 additions & 1 deletion docker/test-ubi9.dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ RUN yum install -y --allowerasing \
curl \
gcc \
gcc-c++ \
make
make \
gpg
COPY ./scripts/install-libarchive.sh /
RUN /install-libarchive.sh

RUN curl -L https://swift.org/keys/all-keys.asc | gpg --import

# tools
RUN mkdir -p $HOME/.tools
RUN echo 'export PATH="$HOME/.tools:$PATH"' >> $HOME/.profile
4 changes: 3 additions & 1 deletion docker/test.dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ ENV LANG en_US.UTF-8
ENV LANGUAGE en_US.UTF-8

# dependencies
RUN apt-get update --fix-missing && apt-get install -y curl build-essential
RUN apt-get update --fix-missing && apt-get install -y curl build-essential gpg
COPY ./scripts/install-libarchive.sh /
RUN /install-libarchive.sh

RUN curl -L https://swift.org/keys/all-keys.asc | gpg --import

# tools
RUN mkdir -p $HOME/.tools
RUN echo 'export PATH="$HOME/.tools:$PATH"' >> $HOME/.profile
Loading