# SPDX-License-Identifier: BSD-2-Clause # SPDX-FileCopyrightText: 2023 KDE e.V. # SPDX-FileContributor: Ingo Klöcker import logging import os import re import shutil from pathlib import Path from tempfile import NamedTemporaryFile, TemporaryDirectory from typing import Dict, List, Optional from zipfile import ZipFile import sftpnotary.log from sftpnotary import config, projects, util from sftpnotary.core import Job, SignFileResponse, SingleFileRequest, TaskProcessor, Worker from sftpnotary.exceptions import Error from sftpnotary.sftp import SFTPClient, SFTPError log = logging.getLogger(__name__) SignWindowsBinariesRequest = SingleFileRequest SignWindowsBinariesResponse = SignFileResponse class SignWindowsBinariesJob(Job): archiveName = "binaries.zip" def __init__( self, sftp: SFTPClient, filePaths: List[Path], logFolder: Path, token: Optional[str] = None, projectPath: Optional[str] = None, branch: Optional[str] = None, ): super().__init__(sftp) self.filePaths = filePaths self.workingDirectory: Optional[TemporaryDirectory] = None if len(self.filePaths) == 1: self.request = SignWindowsBinariesRequest( fileName=self.filePaths[0].name, token=token, projectPath=projectPath, branch=branch ) else: self.request = SignWindowsBinariesRequest( fileName=self.archiveName, token=token, projectPath=projectPath, branch=branch ) self.logFolder = logFolder self.originalFilePaths: Dict[str, Path] = {} def start(self): if len(self.filePaths) == 1: self.localFilePath = self.filePaths[0] else: self.workingDirectory = TemporaryDirectory() self.localFilePath = Path(self.workingDirectory.name) / self.archiveName self.packageBinaries(self.localFilePath) self.sftp.upload(self.localFilePath, self.task.path() / self.request.fileName) self.task.putRequest(self.request) self.taskQueue.commitTask(self.task) def packageBinaries(self, archivePath): # put all files to sign into a zip archive; to avoid problems with paths # and weird file names we use consecutive numbers with the original extension # as names in the archive with ZipFile(archivePath, "w") as zipFile: for number, binaryPath in enumerate(self.filePaths, start=1): arcName = str(number) + binaryPath.suffix self.originalFilePaths[arcName] = binaryPath log.debug(f"Adding {binaryPath} as {arcName} to {archivePath.name}") zipFile.write(binaryPath, arcname=arcName) def extractSignedBinaries(self, archivePath): log.debug(f"Extracting signed binaries from {archivePath.name}") # ZipFile cannot extract a file with another filename than the one listed # in the archive; therefore, we extract all expected files to our working # directory (which only contains the archive) and then we "move" the # extracted signed binaries to their original locations extractPath = Path(self.workingDirectory.name) with ZipFile(archivePath, "r") as zipFile: filesToExtract = [arcName for arcName in zipFile.namelist() if arcName in self.originalFilePaths] zipFile.extractall(path=extractPath, members=filesToExtract) for arcName in filesToExtract: originalFilePath = self.originalFilePaths[arcName] log.debug(f"Moving {arcName} to {originalFilePath}") # use shutil.copyfile() + os.unlink() because there's no sane way to do a 'mv' # that overwrites existing files and that works across filesystem boundaries shutil.copyfile(extractPath / arcName, originalFilePath) os.unlink(extractPath / arcName) def waitForCompletion(self): self.taskQueue.waitForCompletion(self.task) log.debug(f"Fetching response of task '{self.task.id}' ...") response = None try: response = self.task.fetchResponse(SignWindowsBinariesResponse) except SFTPError as e: log.error("Error: %s", e) if response is None: log.error("Error: Got no response. Task failed.") elif not response.signedFileName: log.error("Error: No signed file to download.") elif "/" in response.signedFileName: log.error(f"Error: Suspicious file name of signed file: {response.signedFileName!r}.") else: try: remotePath = self.task.path() / response.signedFileName localPath = self.localFilePath # overwrite original (unsigned) file self.sftp.download(remotePath, localPath) if len(self.filePaths) > 1: self.extractSignedBinaries(self.localFilePath) self.success = True except SFTPError as e: log.error("Error: %s", e) if self.workingDirectory is not None: self.workingDirectory.cleanup() self.downloadLogFiles(self.logFolder) self.taskQueue.removeTask(self.task) # expected file names are a number optionally followed by a suffix; # in particular, they have no path components _expectedFileNamesRegExp = re.compile(r"\d+(?:\.\w*)", re.ASCII) def filterBinaries(fileNames: List[str]) -> List[str]: return [fileName for fileName in fileNames if _expectedFileNamesRegExp.fullmatch(fileName)] def extractUnsignedBinaries(archivePath, extractPath): with ZipFile(archivePath, "r") as zipFile: filesToExtract = filterBinaries(zipFile.namelist()) zipFile.extractall(path=extractPath, members=filesToExtract) log.debug(f"Extracted files {filesToExtract} from {archivePath.name} to {extractPath}") return filesToExtract def packageSignedBinaries(archivePath, filePaths): with ZipFile(archivePath, "w") as zipFile: for signedFilePath in filePaths: arcName = signedFilePath.name log.debug(f"Adding {signedFilePath.name} to {archivePath.name}") zipFile.write(signedFilePath, arcname=arcName) class SignWindowsBinariesProcessor(TaskProcessor): requestClass = SignWindowsBinariesRequest def doProcess(self): # 1. download the binary/installer or the archive with the binaries to sign # 2. extract the binaries to sign (if necessary) # 3. sign the binaries # 4. package the signed binaries (if necessary) # 5. upload the signed binary/installer or the archive with the signed binaries remoteUnsignedFilePath = self.task.path() / self.request.fileName signSingleFile = remoteUnsignedFilePath.suffix != ".zip" with TemporaryDirectory(dir=config.settings.get("General", "TempDirectory")) as workPathName: workPath = Path(workPathName) if signSingleFile: # use simple file names locally to avoid issues with user provided file names localFilePath = workPath / ("file" + remoteUnsignedFilePath.suffix) self.sftp.download(remoteUnsignedFilePath, localFilePath) if localFilePath.suffix == ".appx": self.verifyApplicationId(localFilePath) self.signFiles([localFilePath.name], path=localFilePath.parent) localSignedFilePath = localFilePath # we assume a single suffix remoteSignedFileName = f"{remoteUnsignedFilePath.stem}-signed{remoteUnsignedFilePath.suffix}" else: # use simple file names locally to avoid issues with user provided file names localUnsignedFilePath = workPath / "unsigned.zip" self.sftp.download(remoteUnsignedFilePath, localUnsignedFilePath) extractPath = workPath / "files" os.mkdir(extractPath) fileNames = extractUnsignedBinaries(localUnsignedFilePath, extractPath) self.signFiles(fileNames, path=extractPath) filePaths = [extractPath / extractedFile for extractedFile in fileNames] localSignedFilePath = workPath / "signed.zip" packageSignedBinaries(localSignedFilePath, filePaths) remoteSignedFileName = localSignedFilePath.name try: self.sftp.upload(localSignedFilePath, self.task.path() / remoteSignedFileName) return SignWindowsBinariesResponse(signedFileName=remoteSignedFileName) except Error as e: log.error("Error: %s", e) except SFTPError as e: log.error("Error: %s", e) def verifyApplicationId(self, appxFilePath): allowedApplicationId = projects.settings.get(self.request.projectPath, self.request.branch, "applicationid") applicationId = self.getApplicationId(appxFilePath) if applicationId != allowedApplicationId: raise Error( f"The application ID of the APPX ({applicationId}) does not match the allowed application ID " f"({allowedApplicationId})." ) def getApplicationId(self, appxFilePath) -> str: with ZipFile(appxFilePath) as appx: with appx.open("AppxManifest.xml") as manifest: # XML processing is dangerous; therefore, simply parse the XML as text # https://docs.python.org/3/library/xml.html#xml-vulnerabilities for line in manifest.readlines(): # look for a line like