From 651ababebfea298afb7dc367ff44144113da0df3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Duval?= Date: Wed, 20 Nov 2024 21:46:09 +0100 Subject: [PATCH] check signatures for download and git fetchers download: add SOURCE_SIG_URI for the signature file URI git: add ?signed on the SOURCE_URI to have signed tags and commits checked this uses gpg to verify. the public key has to exist in the ring. --- HaikuPorter/Port.py | 5 ++ HaikuPorter/RecipeAttributes.py | 14 ++++++ HaikuPorter/Source.py | 42 +++++++++++++++-- HaikuPorter/SourceFetcher.py | 84 +++++++++++++++++++++++++++++++-- 4 files changed, 137 insertions(+), 8 deletions(-) diff --git a/HaikuPorter/Port.py b/HaikuPorter/Port.py index 599e1891..96c598c4 100644 --- a/HaikuPorter/Port.py +++ b/HaikuPorter/Port.py @@ -639,6 +639,7 @@ def downloadSource(self): for source in self.sources: source.fetch(self) source.validateChecksum(self) + source.validateFingerprint(self) def unpackSource(self): """Unpack the source archive(s)""" @@ -982,11 +983,15 @@ def _parseRecipeFile(self, showWarnings, forceAllowUnstable=False): basedOnSourcePackage = False ## REFACTOR it looks like this method should be setup and dispatch + pgpkeys = keys['PGPKEYS'] if 'PGPKEYS' in keys else None + for index in sorted(list(keys['SOURCE_URI'].keys()), key=cmp_to_key(naturalCompare)): source = Source(self, index, keys['SOURCE_URI'][index], keys['SOURCE_FILENAME'].get(index, None), keys['CHECKSUM_SHA256'].get(index, None), + keys['SOURCE_SIG_URI'].get(index, None), + pgpkeys, keys['SOURCE_DIR'].get(index, None), keys['PATCHES'].get(index, []), keys['ADDITIONAL_FILES'].get(index, [])) diff --git a/HaikuPorter/RecipeAttributes.py b/HaikuPorter/RecipeAttributes.py index 216c3882..ffd969eb 100644 --- a/HaikuPorter/RecipeAttributes.py +++ b/HaikuPorter/RecipeAttributes.py @@ -61,6 +61,13 @@ def getRecipeFormatVersion(): 'extendable': Extendable.NO, 'indexable': False, }, + 'PGPKEYS': { + 'type': list, + 'required': False, + 'default': {}, + 'extendable': Extendable.NO, + 'indexable': True, + }, # indexable, i.e. per-source attributes 'ADDITIONAL_FILES': { @@ -105,6 +112,13 @@ def getRecipeFormatVersion(): 'extendable': Extendable.NO, 'indexable': True, }, + 'SOURCE_SIG_URI': { + 'type': list, + 'required': False, + 'default': {}, + 'extendable': Extendable.NO, + 'indexable': True, + }, # extendable, i.e. per-package attributes 'ARCHITECTURES': { diff --git a/HaikuPorter/Source.py b/HaikuPorter/Source.py index 08f88753..e7dcaa31 100644 --- a/HaikuPorter/Source.py +++ b/HaikuPorter/Source.py @@ -26,12 +26,14 @@ # -- A source archive (or checkout) ------------------------------------------- class Source(object): - def __init__(self, port, index, uris, fetchTargetName, checksum, - sourceDir, patches, additionalFiles): + def __init__(self, port, index, uris, fetchTargetName, checksum, sigUri, + pgpkeys, sourceDir, patches, additionalFiles): self.index = index self.uris = uris self.fetchTargetName = fetchTargetName self.checksum = checksum + self.sigUri = sigUri + self.pgpkeys = pgpkeys self.patches = patches self.additionalFiles = additionalFiles @@ -130,7 +132,7 @@ def fetch(self, port): (unusedType, baseUri, rev) = parseCheckoutUri(uri) if baseUri == storedBaseUri: self.sourceFetcher \ - = createSourceFetcher(uri, self.fetchTarget) + = createSourceFetcher(uri, self.fetchTarget, self.sigUri) if rev != storedRev: self.sourceFetcher.updateToRev(rev) storeStringInFile(uri, self.fetchTarget + '.uri') @@ -144,7 +146,7 @@ def fetch(self, port): warn("Stored SOURCE_URI is no longer in recipe, automatic " u"repository update won't work") self.sourceFetcher \ - = createSourceFetcher(storedUri, self.fetchTarget) + = createSourceFetcher(storedUri, self.fetchTarget, self.sigUri) return else: @@ -158,7 +160,7 @@ def fetch(self, port): for uri in self.uris: try: info('\nDownloading: ' + uri + ' ...') - sourceFetcher = createSourceFetcher(uri, self.fetchTarget) + sourceFetcher = createSourceFetcher(uri, self.fetchTarget, self.sigUri) sourceFetcher.fetch() # ok, fetching the source was successful, we keep the source @@ -271,6 +273,36 @@ def validateChecksum(self, port): port.setFlag('validate', self.index) + def validateFingerprint(self, port): + """Make sure that the fingerprint matches the expectations""" + + if not self.sourceFetcher.sourceShouldBeVerified: + return + + # Check to see if the source was already verified. + if port.checkFlag('verified', self.index) and not getOption('force'): + info('Skipping fingerprint validation of ' + self.fetchTargetName) + return + + info('Validating fingerprint of ' + self.fetchTargetName) + hexdigest = fingerprint = self.sourceFetcher.findSignature() + if hexdigest is None: + sysExit('Found no fingerprint or no public key to match') + + if self.pgpkeys is not None and len(self.pgpkeys.keys()) > 0: + for index in self.pgpkeys.keys(): + if hexdigest == self.pgpkeys.get(index)[0]: + port.setFlag('verified', self.index) + return + sysExit('Found unexpected fingerprint: ' + hexdigest) + else: + warn('----- PGPKEYS TEMPLATE -----') + warn('PGPKEYS=(%(digest)s)' % { + "digest": hexdigest}) + warn('-----------------------------') + + port.setFlag('verified', self.index) + @property def isFromSourcePackage(self): """Determines whether or not this source comes from a source package""" diff --git a/HaikuPorter/SourceFetcher.py b/HaikuPorter/SourceFetcher.py index ee8aff4f..13f01de3 100644 --- a/HaikuPorter/SourceFetcher.py +++ b/HaikuPorter/SourceFetcher.py @@ -125,6 +125,7 @@ class SourceFetcherForBazaar(object): def __init__(self, uri, fetchTarget): self.fetchTarget = fetchTarget self.sourceShouldBeValidated = False + self.sourceShouldBeVerified = False (unusedType, self.uri, self.rev) = parseCheckoutUri(uri) @@ -158,6 +159,7 @@ class SourceFetcherForCvs(object): def __init__(self, uri, fetchTarget): self.fetchTarget = fetchTarget self.sourceShouldBeValidated = False + self.sourceShouldBeVerified = False (unusedType, uri, self.rev) = parseCheckoutUri(uri) @@ -199,10 +201,12 @@ def unpack(self, sourceBaseDir, sourceSubDir, foldSubDir): # -- Fetches sources via wget ------------------------------------------------- class SourceFetcherForDownload(object): - def __init__(self, uri, fetchTarget): + def __init__(self, uri, fetchTarget, sigUri): self.fetchTarget = fetchTarget self.uri = uri + self.sigUri = sigUri self.sourceShouldBeValidated = True + self.sourceShouldBeVerified = self.sigUri is not None def fetch(self): downloadDir = os.path.dirname(self.fetchTarget) @@ -244,12 +248,52 @@ def unpack(self, sourceBaseDir, sourceSubDir, foldSubDir): def calcChecksum(self): return calcChecksumFile(self.fetchTarget) + def findSignature(self): + ensureCommandIsAvailable('wget') + ensureCommandIsAvailable('gpg') + downloadDir = os.path.dirname(self.fetchTarget) + sigFilename = self.sigUri[0] + sigFilename = sigFilename[sigFilename.rindex('/') + 1:] + filename = self.fetchTarget[self.fetchTarget.rindex('/') + 1:] + args = ['wget', '-c', '--tries=1', '--timeout=10', self.sigUri[0]] + + code = 0 + for tries in range(0, 3): + process = Popen(args, cwd=downloadDir, stdout=PIPE, stderr=STDOUT) + for line in iter(process.stdout.readline, b''): + info(line.decode('utf-8')[:-1]) + process.stdout.close() + code = process.wait() + if code in (0, 2, 6, 8): + # 0: success + # 2: parse error of command line + # 6: auth failure + # 8: error response from server + break + + time.sleep(3) + + if code: + raise CalledProcessError(code, args) + command = 'gpg --verify --status-fd 1 %s %s 2>/dev/null' % (sigFilename, filename) + try: + output = check_output(command, shell=True, cwd=downloadDir).decode('utf-8') + except CalledProcessError as e: + return None + for line in output.split('\n'): + if 'VALIDSIG' in line: + print(line) + return line.split(' ')[11] + return None + + # -- Fetches sources via fossil ----------------------------------------------- class SourceFetcherForFossil(object): def __init__(self, uri, fetchTarget): self.fetchTarget = fetchTarget self.sourceShouldBeValidated = False + self.sourceShouldBeVerified = False (unusedType, self.uri, self.rev) = parseCheckoutUri(uri) @@ -286,13 +330,19 @@ class SourceFetcherForGit(object): def __init__(self, uri, fetchTarget): self.fetchTarget = fetchTarget self.sourceShouldBeValidated = False + self.sourceShouldBeVerified = False + self.isCommit=False (unusedType, self.uri, self.rev) = parseCheckoutUri(uri) if not self.rev: self.rev = 'HEAD' if self.rev.startswith('tag=') or self.rev.startswith('commit='): + self.isCommit=self.rev.startswith('commit=') self.rev=self.rev[self.rev.find('=') + 1:] self.sourceShouldBeValidated = True + if self.uri.endswith('?signed'): + self.sourceShouldBeVerified = True + self.uri=self.uri[:-len('?signed')] def fetch(self): if not self.sourceShouldBeValidated: @@ -317,6 +367,11 @@ def updateToRev(self, rev): ensureCommandIsAvailable('git') self.rev = rev + if self.rev.startswith('tag=') or self.rev.startswith('commit='): + self.isCommit=self.rev.startswith('commit=') + self.rev=self.rev[self.rev.find('=') + 1:] + self.sourceShouldBeValidated = True + command = 'git rev-list --max-count=1 %s &>/dev/null' % self.rev try: output = check_output(command, shell=True, cwd=self.fetchTarget).decode('utf-8') @@ -358,6 +413,25 @@ def calcChecksum(self): checksum = output[:output.find(' ')] return checksum + def findSignature(self): + ensureCommandIsAvailable('git') + ensureCommandIsAvailable('gpg') + command = 'GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git ' + if self.isCommit: + command += 'verify-commit' + else: + command += 'verify-tag' + command += ' --raw "%s" 2>&1' % (self.rev) + try: + output = check_output(command, shell=True, cwd=self.fetchTarget).decode('utf-8') + except CalledProcessError as e: + warn("COULDN'T FIND PUBLIC KEY") + return None + for line in output.split('\n'): + if 'VALIDSIG' in line: + return line.split(' ')[11] + return None + # -- Fetches sources from local disk ------------------------------------------ class SourceFetcherForLocalFile(object): @@ -365,6 +439,7 @@ def __init__(self, uri, fetchTarget): self.fetchTarget = fetchTarget self.uri = uri self.sourceShouldBeValidated = False + self.sourceShouldBeVerified = False def fetch(self): # just symlink the local file to fetchTarget (if it exists) @@ -390,6 +465,7 @@ class SourceFetcherForMercurial(object): def __init__(self, uri, fetchTarget): self.fetchTarget = fetchTarget self.sourceShouldBeValidated = False + self.sourceShouldBeVerified = False (unusedType, self.uri, self.rev) = parseCheckoutUri(uri) @@ -436,6 +512,7 @@ def __init__(self, uri, fetchTarget): self.fetchTarget = fetchTarget self.uri = uri self.sourceShouldBeValidated = False + self.sourceShouldBeVerified = False self.sourcePackagePath = self.uri[4:] def fetch(self): @@ -473,6 +550,7 @@ class SourceFetcherForSubversion(object): def __init__(self, uri, fetchTarget): self.fetchTarget = fetchTarget self.sourceShouldBeValidated = False + self.sourceShouldBeVerified = False (unusedType, self.uri, self.rev) = parseCheckoutUri(uri) @@ -499,7 +577,7 @@ def unpack(self, sourceBaseDir, sourceSubDir, foldSubDir): # -- source fetcher factory function for given URI ---------------------------- -def createSourceFetcher(uri, fetchTarget): +def createSourceFetcher(uri, fetchTarget, sigUri): """Creates an appropriate source fetcher for the given URI""" lowerUri = uri.lower() @@ -514,7 +592,7 @@ def createSourceFetcher(uri, fetchTarget): elif lowerUri.startswith('hg'): return SourceFetcherForMercurial(uri, fetchTarget) elif lowerUri.startswith('http') or lowerUri.startswith('ftp'): - return SourceFetcherForDownload(uri, fetchTarget) + return SourceFetcherForDownload(uri, fetchTarget, sigUri) elif lowerUri.startswith('pkg:'): return SourceFetcherForSourcePackage(uri, fetchTarget) elif lowerUri.startswith('svn'):