Skip to content

Commit

Permalink
check signatures for download and git fetchers
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
korli committed Nov 23, 2024
1 parent a7cef38 commit 651abab
Show file tree
Hide file tree
Showing 4 changed files with 137 additions and 8 deletions.
5 changes: 5 additions & 0 deletions HaikuPorter/Port.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)"""
Expand Down Expand Up @@ -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, []))
Expand Down
14 changes: 14 additions & 0 deletions HaikuPorter/RecipeAttributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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': {
Expand Down Expand Up @@ -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': {
Expand Down
42 changes: 37 additions & 5 deletions HaikuPorter/Source.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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')
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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"""
Expand Down
84 changes: 81 additions & 3 deletions HaikuPorter/SourceFetcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand All @@ -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')
Expand Down Expand Up @@ -358,13 +413,33 @@ 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):
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)
Expand All @@ -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)

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)

Expand All @@ -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()
Expand All @@ -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'):
Expand Down

0 comments on commit 651abab

Please sign in to comment.