1 #----------------------------------------------------------------------
2 # Copyright (c) 2014 Raytheon BBN Technologies
4 # Permission is hereby granted, free of charge, to any person obtaining
5 # a copy of this software and/or hardware specification (the "Work") to
6 # deal in the Work without restriction, including without limitation the
7 # rights to use, copy, modify, merge, publish, distribute, sublicense,
8 # and/or sell copies of the Work, and to permit persons to whom the Work
9 # is furnished to do so, subject to the following conditions:
11 # The above copyright notice and this permission notice shall be
12 # included in all copies or substantial portions of the Work.
14 # THE WORK IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16 # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17 # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
18 # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
19 # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 # OUT OF OR IN CONNECTION WITH THE WORK OR THE USE OR OTHER DEALINGS
22 #----------------------------------------------------------------------
25 from dateutil import parser as du_parser, tz as du_tz
31 from xml.dom.minidom import *
32 from StringIO import StringIO
34 from sfa.util.sfatime import SFATIME_FORMAT
36 from sfa.trust.certificate import Certificate
37 from sfa.trust.credential import Credential, signature_template, HAVELXML
38 from sfa.trust.abac_credential import ABACCredential, ABACElement
39 from sfa.trust.credential_factory import CredentialFactory
40 from sfa.trust.gid import GID
42 # Routine to validate that a speaks-for credential
43 # says what it claims to say:
44 # It is a signed credential wherein the signer S is attesting to the
46 # S.speaks_for(S)<-T Or "S says that T speaks for S"
48 # Requires that openssl be installed and in the path
49 # create_speaks_for requires that xmlsec1 be on the path
51 # Simple XML helper functions
53 # Find the text associated with first child text node
54 def findTextChildValue(root):
55 child = findChildNamed(root, '#text')
56 if child: return str(child.nodeValue)
59 # Find first child with given name
60 def findChildNamed(root, name):
61 for child in root.childNodes:
62 if child.nodeName == name:
66 # Write a string to a tempfile, returning name of tempfile
67 def write_to_tempfile(str):
68 str_fd, str_file = tempfile.mkstemp()
74 # Run a subprocess and return output
75 def run_subprocess(cmd, stdout, stderr):
77 proc = subprocess.Popen(cmd, stdout=stdout, stderr=stderr)
80 output = proc.stdout.read()
82 output = proc.returncode
84 except Exception as e:
85 raise Exception("Failed call to subprocess '%s': %s" % (" ".join(cmd), e))
87 def get_cert_keyid(gid):
88 """Extract the subject key identifier from the given certificate.
89 Return they key id as lowercase string with no colon separators
90 between pairs. The key id as shown in the text output of a
91 certificate are in uppercase with colon separators.
94 raw_key_id = gid.get_extension('subjectKeyIdentifier')
95 # Raw has colons separating pairs, and all characters are upper case.
96 # Remove the colons and convert to lower case.
97 keyid = raw_key_id.replace(':', '').lower()
100 # Pull the cert out of a list of certs in a PEM formatted cert string
101 def grab_toplevel_cert(cert):
102 start_label = '-----BEGIN CERTIFICATE-----'
103 if cert.find(start_label) > -1:
104 start_index = cert.find(start_label) + len(start_label)
107 end_label = '-----END CERTIFICATE-----'
108 end_index = cert.find(end_label)
109 first_cert = cert[start_index:end_index]
110 pieces = first_cert.split('\n')
111 first_cert = "".join(pieces)
114 # Validate that the given speaks-for credential represents the
115 # statement User.speaks_for(User)<-Tool for the given user and tool certs
116 # and was signed by the user
118 # Boolean indicating whether the given credential
120 # is an ABAC credential
121 # was signed by the user associated with the speaking_for_urn
122 # is verified by xmlsec1
123 # asserts U.speaks_for(U)<-T ("user says that T may speak for user")
124 # If schema provided, validate against schema
125 # is trusted by given set of trusted roots (both user cert and tool cert)
126 # String user certificate of speaking_for user if the above tests succeed
128 # Error message indicating why the speaks_for call failed ("" otherwise)
129 def verify_speaks_for(cred, tool_gid, speaking_for_urn,
130 trusted_roots, schema=None, logger=None):
132 # Credential has not expired
133 if cred.expiration and cred.expiration < datetime.datetime.utcnow():
134 return False, None, "ABAC Credential expired at %s (%s)" % (cred.expiration.strftime(SFATIME_FORMAT), cred.get_summary_tostring())
137 if cred.get_cred_type() != ABACCredential.ABAC_CREDENTIAL_TYPE:
138 return False, None, "Credential not of type ABAC but %s" % cred.get_cred_type
140 if cred.signature is None or cred.signature.gid is None:
141 return False, None, "Credential malformed: missing signature or signer cert. Cred: %s" % cred.get_summary_tostring()
142 user_gid = cred.signature.gid
143 user_urn = user_gid.get_urn()
145 # URN of signer from cert must match URN of 'speaking-for' argument
146 if user_urn != speaking_for_urn:
147 return False, None, "User URN from cred doesn't match speaking_for URN: %s != %s (cred %s)" % \
148 (user_urn, speaking_for_urn, cred.get_summary_tostring())
150 tails = cred.get_tails()
152 return False, None, "Invalid ABAC-SF credential: Need exactly 1 tail element, got %d (%s)" % \
153 (len(tails), cred.get_summary_tostring())
155 user_keyid = get_cert_keyid(user_gid)
156 tool_keyid = get_cert_keyid(tool_gid)
157 subject_keyid = tails[0].get_principal_keyid()
159 head = cred.get_head()
160 principal_keyid = head.get_principal_keyid()
161 role = head.get_role()
163 # Credential must pass xmlsec1 verify
164 cred_file = write_to_tempfile(cred.save_to_string())
167 for x in trusted_roots:
168 cert_args += ['--trusted-pem', x.filename]
169 # FIXME: Why do we not need to specify the --node-id option as credential.py does?
170 xmlsec1_args = [cred.xmlsec_path, '--verify'] + cert_args + [ cred_file]
171 output = run_subprocess(xmlsec1_args, stdout=None, stderr=subprocess.PIPE)
175 # xmlsec errors have a msg= which is the interesting bit.
176 # But does this go to stderr or stdout? Do we have it here?
177 mstart = verified.find("msg=")
179 if mstart > -1 and len(verified) > 4:
181 mend = verified.find('\\', mstart)
182 msg = verified[mstart:mend]
185 return False, None, "ABAC credential failed to xmlsec1 verify: %s" % msg
187 # Must say U.speaks_for(U)<-T
188 if user_keyid != principal_keyid or \
189 tool_keyid != subject_keyid or \
190 role != ('speaks_for_%s' % user_keyid):
191 return False, None, "ABAC statement doesn't assert U.speaks_for(U)<-T (%s)" % cred.get_summary_tostring()
193 # If schema provided, validate against schema
194 if HAVELXML and schema and os.path.exists(schema):
195 from lxml import etree
196 tree = etree.parse(StringIO(cred.xml))
197 schema_doc = etree.parse(schema)
198 xmlschema = etree.XMLSchema(schema_doc)
199 if not xmlschema.validate(tree):
200 error = xmlschema.error_log.last_error
201 message = "%s: %s (line %s)" % (cred.get_summary_tostring(), error.message, error.line)
202 return False, None, ("XML Credential schema invalid: %s" % message)
205 # User certificate must validate against trusted roots
207 user_gid.verify_chain(trusted_roots)
209 return False, None, \
210 "Cred signer (user) cert not trusted: %s" % e
212 # Tool certificate must validate against trusted roots
214 tool_gid.verify_chain(trusted_roots)
216 return False, None, \
217 "Tool cert not trusted: %s" % e
219 return True, user_gid, ""
221 # Determine if this is a speaks-for context. If so, validate
222 # And return either the tool_cert (not speaks-for or not validated)
223 # or the user cert (validated speaks-for)
225 # credentials is a list of GENI-style credentials:
226 # Either a cred string xml string, or Credential object of a tuple
227 # [{'geni_type' : geni_type, 'geni_value : cred_value,
228 # 'geni_version' : version}]
229 # caller_gid is the raw X509 cert gid
230 # options is the dictionary of API-provided options
231 # trusted_roots is a list of Certificate objects from the system
232 # trusted_root directory
233 # Optionally, provide an XML schema against which to validate the credential
234 def determine_speaks_for(logger, credentials, caller_gid, speaking_for_xrn, trusted_roots, schema=None):
236 speaking_for_urn = Xrn (speaking_for_xrn.strip()).get_urn()
237 for cred in credentials:
238 # Skip things that aren't ABAC credentials
239 if type(cred) == dict:
240 if cred['geni_type'] != ABACCredential.ABAC_CREDENTIAL_TYPE: continue
241 cred_value = cred['geni_value']
242 elif isinstance(cred, Credential):
243 if not isinstance(cred, ABACCredential):
248 if CredentialFactory.getType(cred) != ABACCredential.ABAC_CREDENTIAL_TYPE: continue
251 # If the cred_value is xml, create the object
252 if not isinstance(cred_value, ABACCredential):
253 cred = CredentialFactory.createCred(cred_value)
255 # print "Got a cred to check speaksfor for: %s" % cred.get_summary_tostring()
256 # #cred.dump(True, True)
257 # print "Caller: %s" % caller_gid.dump_string(2, True)
258 # See if this is a valid speaks_for
259 is_valid_speaks_for, user_gid, msg = \
260 verify_speaks_for(cred,
261 caller_gid, speaking_for_urn, \
262 trusted_roots, schema, logger=logger)
264 if is_valid_speaks_for:
265 return user_gid # speaks-for
268 logger.info("Got speaks-for option but not a valid speaks_for with this credential: %s" % msg)
270 print "Got a speaks-for option but not a valid speaks_for with this credential: " + msg
271 return caller_gid # Not speaks-for
273 # Create an ABAC Speaks For credential using the ABACCredential object and it's encode&sign methods
274 def create_sign_abaccred(tool_gid, user_gid, ma_gid, user_key_file, cred_filename, dur_days=365):
275 print "Creating ABAC SpeaksFor using ABACCredential...\n"
276 # Write out the user cert
277 from tempfile import mkstemp
278 ma_str = ma_gid.save_to_string()
279 user_cert_str = user_gid.save_to_string()
280 if not user_cert_str.endswith(ma_str):
281 user_cert_str += ma_str
282 fp, user_cert_filename = mkstemp(suffix='cred', text=True)
283 fp = os.fdopen(fp, "w")
284 fp.write(user_cert_str)
288 cred = ABACCredential()
289 cred.set_issuer_keys(user_key_file, user_cert_filename)
290 tool_urn = tool_gid.get_urn()
291 user_urn = user_gid.get_urn()
292 user_keyid = get_cert_keyid(user_gid)
293 tool_keyid = get_cert_keyid(tool_gid)
294 cred.head = ABACElement(user_keyid, user_urn, "speaks_for_%s" % user_keyid)
295 cred.tails.append(ABACElement(tool_keyid, tool_urn))
296 cred.set_expiration(datetime.datetime.utcnow() + datetime.timedelta(days=dur_days))
297 cred.expiration = cred.expiration.replace(microsecond=0)
299 # Produce the cred XML
305 cred.save_to_file(cred_filename)
306 print "Created ABAC credential: '%s' in file %s" % \
307 (cred.get_summary_tostring(), cred_filename)
309 # FIXME: Assumes xmlsec1 is on path
310 # FIXME: Assumes signer is itself signed by an 'ma_gid' that can be trusted
311 def create_speaks_for(tool_gid, user_gid, ma_gid, \
312 user_key_file, cred_filename, dur_days=365):
313 tool_urn = tool_gid.get_urn()
314 user_urn = user_gid.get_urn()
316 header = '<?xml version="1.0" encoding="UTF-8"?>'
320 signature_template + \
322 template = header + '\n' + \
323 '<signed-credential '
324 template += 'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://www.geni.net/resources/credential/2/credential.xsd" xsi:schemaLocation="http://www.protogeni.net/resources/credential/ext/policy/1 http://www.protogeni.net/resources/credential/ext/policy/1/policy.xsd"'
325 template += '>\n' + \
326 '<credential xml:id="%s">\n' + \
327 '<type>abac</type>\n' + \
331 '<target_gid/>\n' + \
332 '<target_urn/>\n' + \
334 '<expires>%s</expires>' +\
337 '<version>%s</version>\n' + \
339 '<ABACprincipal><keyid>%s</keyid><mnemonic>%s</mnemonic></ABACprincipal>\n' +\
340 '<role>speaks_for_%s</role>\n' + \
343 '<ABACprincipal><keyid>%s</keyid><mnemonic>%s</mnemonic></ABACprincipal>\n' +\
347 '</credential>\n' + \
349 '</signed-credential>\n'
352 credential_duration = datetime.timedelta(days=dur_days)
353 expiration = datetime.datetime.utcnow() + credential_duration
354 expiration_str = expiration.strftime(SFATIME_FORMAT)
357 user_keyid = get_cert_keyid(user_gid)
358 tool_keyid = get_cert_keyid(tool_gid)
359 unsigned_cred = template % (reference, expiration_str, version, \
360 user_keyid, user_urn, user_keyid, tool_keyid, tool_urn, \
361 reference, reference)
362 unsigned_cred_filename = write_to_tempfile(unsigned_cred)
364 # Now sign the file with xmlsec1
365 # xmlsec1 --sign --privkey-pem privkey.pem,cert.pem
366 # --output signed.xml tosign.xml
367 pems = "%s,%s,%s" % (user_key_file, user_gid.get_filename(),
368 ma_gid.get_filename())
369 # FIXME: assumes xmlsec1 is on path
370 cmd = ['xmlsec1', '--sign', '--privkey-pem', pems,
371 '--output', cred_filename, unsigned_cred_filename]
373 # print " ".join(cmd)
374 sign_proc_output = run_subprocess(cmd, stdout=subprocess.PIPE, stderr=None)
375 if sign_proc_output == None:
376 print "OUTPUT = %s" % sign_proc_output
378 print "Created ABAC credential: '%s speaks_for %s' in file %s" % \
379 (tool_urn, user_urn, cred_filename)
380 os.unlink(unsigned_cred_filename)
384 if __name__ == "__main__":
386 parser = optparse.OptionParser()
387 parser.add_option('--cred_file',
388 help='Name of credential file')
389 parser.add_option('--tool_cert_file',
390 help='Name of file containing tool certificate')
391 parser.add_option('--user_urn',
392 help='URN of speaks-for user')
393 parser.add_option('--user_cert_file',
394 help="filename of x509 certificate of signing user")
395 parser.add_option('--ma_cert_file',
396 help="filename of x509 cert of MA that signed user cert")
397 parser.add_option('--user_key_file',
398 help="filename of private key of signing user")
399 parser.add_option('--trusted_roots_directory',
400 help='Directory of trusted root certs')
401 parser.add_option('--create',
402 help="name of file of ABAC speaksfor cred to create")
403 parser.add_option('--useObject', action='store_true', default=False,
404 help='Use the ABACCredential object to create the credential (default False)')
406 options, args = parser.parse_args(sys.argv)
408 tool_gid = GID(filename=options.tool_cert_file)
411 if options.user_cert_file and options.user_key_file \
412 and options.ma_cert_file:
413 user_gid = GID(filename=options.user_cert_file)
414 ma_gid = GID(filename=options.ma_cert_file)
415 if options.useObject:
416 create_sign_abaccred(tool_gid, user_gid, ma_gid, \
417 options.user_key_file, \
420 create_speaks_for(tool_gid, user_gid, ma_gid, \
421 options.user_key_file, \
424 print "Usage: --create cred_file " + \
425 "--user_cert_file user_cert_file" + \
426 " --user_key_file user_key_file --ma_cert_file ma_cert_file"
429 user_urn = options.user_urn
431 # Get list of trusted rootcerts
432 if options.cred_file and not options.trusted_roots_directory:
433 sys.exit("Must supply --trusted_roots_directory to validate a credential")
435 trusted_roots_directory = options.trusted_roots_directory
437 [Certificate(filename=os.path.join(trusted_roots_directory, file)) \
438 for file in os.listdir(trusted_roots_directory) \
439 if file.endswith('.pem') and file != 'CATedCACerts.pem']
441 cred = open(options.cred_file).read()
443 creds = [{'geni_type' : ABACCredential.ABAC_CREDENTIAL_TYPE, 'geni_value' : cred,
444 'geni_version' : '1'}]
445 gid = determine_speaks_for(None, creds, tool_gid, \
446 {'geni_speaking_for' : user_urn}, \
450 print 'SPEAKS_FOR = %s' % (gid != tool_gid)
451 print "CERT URN = %s" % gid.get_urn()