Skip to content

Commit 7870738

Browse files
Check CernVM image signing
1 parent 17f9dd6 commit 7870738

6 files changed

+133
-12
lines changed

CHANGES

+2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
- Use spaceDir not spaceName in cleanupJoboutputs()
33
- machinetypes directories are now under /var/lib/vcycle/spaces/
44
- Use common Vac Project vcycle.httpd.conf and specific vcycle.httpd.inc
5+
- cernvm_signing_dn option added, to check signatures of CernVM boot
6+
images
57
==================== Changes in Vcycle version 0.7.0 =====================
68
- Use machineoutputs -> joboutputs internally and in options
79
==================== Changes in Vcycle version 0.6.0 =====================

openstack_api.py

+11
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import pprint
3838

3939
import os
40+
import re
4041
import sys
4142
import stat
4243
import time
@@ -337,6 +338,16 @@ def getImageID(self, machinetypeName):
337338

338339
vcycle.vacutils.logLine('Image "' + self.machinetypes[machinetypeName].root_image + '" not found in image service, so uploading')
339340

341+
if self.machinetypes[machinetypeName].cernvm_signing_dn:
342+
cernvmDict = vac.vacutils.getCernvmImageData(self.machinetypes[machinetypeName]._imageFile)
343+
344+
if cernvmDict['verified'] == False:
345+
raise OpenstackError('Failed to verify signature/cert for ' + self.machinetypes[machinetypeName].root_image)
346+
elif re.search(self.machinetypes[machinetypeName].cernvm_signing_dn, cernvmDict['dn']) is None:
347+
raise OpenstackError('Signing DN ' + cernvmDict['dn'] + ' does not match cernvm_signing_dn = ' + self.machinetypes[machinetypeName].cernvm_signing_dn)
348+
else:
349+
vac.vacutils.logLine('Verified image signed by ' + cernvmDict['dn'])
350+
340351
# Try to upload the image
341352
try:
342353
self.machinetypes[machinetypeName]._imageID = self.uploadImage(self.machinetypes[machinetypeName]._imageFile, imageName, imageLastModified)

shared.py

+10-5
Original file line numberDiff line numberDiff line change
@@ -485,7 +485,12 @@ def __init__(self, spaceName, machinetypeName, parser, machinetypeSectionName):
485485
self.root_image = parser.get(machinetypeSectionName, 'root_image')
486486
except Exception as e:
487487
raise VcycleError('root_image is required in [' + machinetypeSectionName + '] (' + str(e) + ')')
488-
488+
489+
try:
490+
self.cernvm_signing_dn = parser.get(machinetypeSectionName, 'cernvm_signing_dn').strip()
491+
except:
492+
self.cernvm_signing_dn = None
493+
489494
try:
490495
self.flavor_name = parser.get(machinetypeSectionName, 'flavor_name')
491496
except Exception as e:
@@ -809,7 +814,7 @@ def httpRequest(self, url, request = None, headers = None, verbose = False, meth
809814
return { 'headers' : outputHeaders, 'response' : None, 'status' : self.curl.getinfo(pycurl.RESPONSE_CODE) }
810815

811816
def publishStatus(self):
812-
"""Write out a GLUE2 JSON file describing this space's status"""
817+
"""Write out a GLUE 2.0 JSON file describing this space's status"""
813818

814819
# This must only be run after the scanMachines method for this space!
815820

@@ -877,9 +882,9 @@ def publishStatus(self):
877882
self.glue2['ExecutionEnvironment'] = executionEnvironments
878883

879884
try:
880-
vcycle.vacutils.createFile('/var/lib/vcycle/spaces/' + self.spaceName + '/glue2.json', json.dumps(self.glue2), 0644, '/var/lib/vcycle/tmp')
885+
vcycle.vacutils.createFile('/var/lib/vcycle/spaces/' + self.spaceName + '/glue-2.0.json', json.dumps(self.glue2), 0644, '/var/lib/vcycle/tmp')
881886
except:
882-
vcycle.vacutils.logLine('Failed writing GLUE2 JSON to /var/lib/vcycle/spaces/' + self.spaceName + '/glue2.json')
887+
vcycle.vacutils.logLine('Failed writing GLUE 2.0 JSON to /var/lib/vcycle/spaces/' + self.spaceName + '/glue-2.0.json')
883888

884889
def _deleteOneMachine(self, machineName):
885890

@@ -1060,7 +1065,7 @@ def _createMachine(self, machinetypeName):
10601065
try:
10611066
userDataContents = vcycle.vacutils.createUserData(shutdownTime = int(time.time() +
10621067
self.machinetypes[machinetypeName].max_wallclock_seconds),
1063-
machinetypePath = '/var/lib/vcycle/spaces/' + self.spaceName + '/machinetypes/' + machinetypeName + '/files',
1068+
machinetypesPath = '/var/lib/vcycle/spaces/' + self.spaceName + '/machinetypes/' + machinetypeName + '/files',
10641069
options = self.machinetypes[machinetypeName].options,
10651070
versionString = 'Vcycle ' + vcycleVersion,
10661071
spaceName = self.spaceName,

vacutils.py

+98-6
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,16 @@
4141
import sys
4242
import stat
4343
import time
44+
import json
4445
import ctypes
4546
import string
4647
import urllib
4748
import StringIO
4849
import tempfile
4950
import calendar
50-
51+
import hashlib
5152
import pycurl
53+
import base64
5254
import M2Crypto
5355

5456
def logLine(text):
@@ -88,7 +90,7 @@ def secondsToHHMMSS(seconds):
8890
mm, ss = divmod(ss, 60)
8991
return '%02d:%02d:%02d' % (hh, mm, ss)
9092

91-
def createUserData(shutdownTime, machinetypePath, options, versionString, spaceName, machinetypeName, userDataPath, hostName, uuidStr,
93+
def createUserData(shutdownTime, machinetypesPath, options, versionString, spaceName, machinetypeName, userDataPath, hostName, uuidStr,
9294
machinefeaturesURL = None, jobfeaturesURL = None, joboutputsURL = None):
9395

9496
# Get raw user_data template file, either from network ...
@@ -121,7 +123,7 @@ def createUserData(shutdownTime, machinetypePath, options, versionString, spaceN
121123
if userDataPath[0] == '/':
122124
userDataFile = userDataPath
123125
else:
124-
userDataFile = machinetypePath + '/' + userDataPath
126+
userDataFile = machinetypesPath + '/' + machinetypeName + '/' + userDataPath
125127

126128
try:
127129
u = open(userDataFile, 'r')
@@ -161,12 +163,12 @@ def createUserData(shutdownTime, machinetypePath, options, versionString, spaceN
161163
if options['user_data_proxy_cert'][0] == '/':
162164
certPath = options['user_data_proxy_cert']
163165
else:
164-
certPath = machinetypePath + '/' + options['user_data_proxy_cert']
166+
certPath = machinetypesPath + '/' + machinetypeName + '/' + options['user_data_proxy_cert']
165167

166168
if options['user_data_proxy_key'][0] == '/':
167169
keyPath = options['user_data_proxy_key']
168170
else:
169-
keyPath = machinetypePath + '/' + options['user_data_proxy_key']
171+
keyPath = machinetypesPath + '/' + machinetypeName + '/' + options['user_data_proxy_key']
170172

171173
try:
172174
if ('legacy_proxy' in options) and options['legacy_proxy']:
@@ -187,7 +189,7 @@ def createUserData(shutdownTime, machinetypePath, options, versionString, spaceN
187189
if oneValue[0] == '/':
188190
f = open(oneValue, 'r')
189191
else:
190-
f = open(machinetypePath + '/' + oneValue, 'r')
192+
f = open(machinetypesPath + '/' + machinetypeName + '/' + oneValue, 'r')
191193

192194
userDataContents = userDataContents.replace('##' + oneOption + '##', f.read())
193195
f.close()
@@ -306,6 +308,96 @@ def makeX509Proxy(certPath, keyPath, expirationTime, isLegacyProxy=False):
306308

307309
return proxyString
308310

311+
def getCernvmImageData(fileName):
312+
313+
data = { 'verified' : False, 'dn' : None }
314+
315+
try:
316+
length = os.stat(fileName).st_size
317+
except Exception as e:
318+
logLine('Failed to get CernVM image size (' + str(e) + ')')
319+
return data
320+
321+
if length <= 65536:
322+
logLine('CernVM image only ' + str(length) + ' bytes long: must be more than 65536')
323+
return data
324+
325+
try:
326+
f = open(fileName, 'r')
327+
except Exception as e:
328+
logLine('Failed to open CernVM image (' + str(e) + ')')
329+
return data
330+
331+
try:
332+
f.seek(-64 * 1024, os.SEEK_END)
333+
metadataBlock = f.read(32 * 1024).rstrip("\x00")
334+
# Quick hack until the metadata section is fixed in the CernVM images (extra comma)
335+
metadataBlock = metadataBlock.replace('HEAD",\n', 'HEAD"\n')
336+
metadataDict = json.loads(metadataBlock)
337+
338+
if 'ucernvm-version' in metadataDict:
339+
data['version'] = metadataDict['ucernvm-version']
340+
except Exception as e:
341+
logLine('Failed to load Metadata Block JSON from CernVM image (' + str(e) + ')')
342+
343+
try:
344+
f.seek(-32 * 1024, os.SEEK_END)
345+
signatureBlock = f.read(32 * 1024).rstrip("\x00")
346+
# Quick hack until the howto-verify section is fixed in the CernVM images (missing commas)
347+
signatureBlock = signatureBlock.replace('signature>"\n', 'signature>",\n').replace('cvm-sign01.cern.ch"\n', 'cvm-sign01.cern.ch",\n')
348+
signatureDict = json.loads(signatureBlock)
349+
except Exception as e:
350+
logLine('Failed to load Signature Block JSON from CernVM image (' + str(e) + ')')
351+
return data
352+
353+
try:
354+
f.seek(0, os.SEEK_SET)
355+
digestableImage = f.read(length - 32 * 1024)
356+
hash = hashlib.sha256(digestableImage)
357+
digest = hash.digest()
358+
except Exception as e:
359+
logLine('Failed to make digest of CernVM image (' + str(e) + ')')
360+
return data
361+
362+
try:
363+
certificate = base64.b64decode(signatureDict['certificate'])
364+
x509 = M2Crypto.X509.load_cert_string(certificate)
365+
rsaPubkey = x509.get_pubkey().get_rsa()
366+
except Exception as e:
367+
logLine('Failed to get X.509 certificate and RSA public key (' + str(e) + ')')
368+
return data
369+
370+
try:
371+
signature = base64.b64decode(signatureDict['signature'])
372+
except:
373+
logLine('Failed to get signature from CernVM Signature Block')
374+
return data
375+
376+
if not rsaPubkey.verify(digest, signature, 'sha256'):
377+
logLine('Certificate and calculated hash do not match given signature')
378+
return data
379+
380+
try:
381+
# This isn't provided by M2Crypto, so we use openssl command
382+
p = os.popen('/usr/bin/openssl verify -CApath /etc/grid-security/certificates >/dev/null', 'w')
383+
p.write(certificate)
384+
385+
if p.close() is None:
386+
try:
387+
dn = str(x509.get_subject())
388+
except Exception as e:
389+
logLine('Failed to get X.509 Subject DN (' + str(e) + ')')
390+
return data
391+
else:
392+
data['verified'] = True
393+
data['dn'] = dn
394+
395+
except Exception as e:
396+
logLine('Failed to run /usr/bin/openssl verify command (' + str(e) + ')')
397+
return data
398+
399+
return data
400+
309401
def getRemoteRootImage(url, imageCache, tmpDir):
310402

311403
try:

vcycle.conf.5

+11
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,17 @@ Alternatively, the images may be files in the local filesystem. If
223223
root_image ends in .iso , then the image will be declared as ISO format
224224
(a CD-ROM image), otherwise as a raw HDD image.
225225

226+
.B cernvm_signing_dn
227+
is used to specify a regular expression to match the DN of an X.509
228+
certificate used to verify the authenticity of the root image. Vcycle
229+
attempts to obtain the certificate and signature from a CernVM Signature
230+
Block at the end of the image file, verifies the
231+
certificate using the CA files in /etc/grid-security/certificates, and
232+
compares the certificate DN to cernvm_signing_dn. If this option is
233+
given, all these verification steps must be satisified for the image
234+
to be used. As of 2015, CernVM images are signed with a DN matching
235+
the regular expression /CN=cvm-sign01\\.cern\\.ch$
236+
226237
.B root_public_key
227238
is the file name of a public key which Vcycle will set up on the IaaS
228239
system and supply to the VMs to allow root ssh access. Setting this

vcycle.spec

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Source: vcycle.tgz
99
URL: http://www.gridpp.ac.uk/vac/
1010
Vendor: GridPP
1111
Packager: Andrew McNab <[email protected]>
12-
Requires: httpd,mod_ssl,python-pycurl,m2crypto,python-requests
12+
Requires: httpd,mod_ssl,python-pycurl,m2crypto,python-requests,openssl
1313

1414
%description
1515
VM lifecycle manager daemon for OpenStack etc

0 commit comments

Comments
 (0)