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.pretty_cred())
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.pretty_cred()
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.pretty_cred())
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.pretty_cred())
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 = cred.get_xmlsec1_path()
172 raise Exception("Could not locate required 'xmlsec1' program")
173 xmlsec1_args = [xmlsec1, '--verify'] + cert_args + [ cred_file]
174 output = run_subprocess(xmlsec1_args, stdout=None, stderr=subprocess.PIPE)
178 # xmlsec errors have a msg= which is the interesting bit.
179 # But does this go to stderr or stdout? Do we have it here?
180 mstart = verified.find("msg=")
182 if mstart > -1 and len(verified) > 4:
184 mend = verified.find('\\', mstart)
185 msg = verified[mstart:mend]
188 return False, None, "ABAC credential failed to xmlsec1 verify: %s" % msg
190 # Must say U.speaks_for(U)<-T
191 if user_keyid != principal_keyid or \
192 tool_keyid != subject_keyid or \
193 role != ('speaks_for_%s' % user_keyid):
194 return False, None, "ABAC statement doesn't assert U.speaks_for(U)<-T (%s)" % cred.pretty_cred()
196 # If schema provided, validate against schema
197 if HAVELXML and schema and os.path.exists(schema):
198 from lxml import etree
199 tree = etree.parse(StringIO(cred.xml))
200 schema_doc = etree.parse(schema)
201 xmlschema = etree.XMLSchema(schema_doc)
202 if not xmlschema.validate(tree):
203 error = xmlschema.error_log.last_error
204 message = "%s: %s (line %s)" % (cred.pretty_cred(), error.message, error.line)
205 return False, None, ("XML Credential schema invalid: %s" % message)
208 # User certificate must validate against trusted roots
210 user_gid.verify_chain(trusted_roots)
212 return False, None, \
213 "Cred signer (user) cert not trusted: %s" % e
215 # Tool certificate must validate against trusted roots
217 tool_gid.verify_chain(trusted_roots)
219 return False, None, \
220 "Tool cert not trusted: %s" % e
222 return True, user_gid, ""
224 # Determine if this is a speaks-for context. If so, validate
225 # And return either the tool_cert (not speaks-for or not validated)
226 # or the user cert (validated speaks-for)
228 # credentials is a list of GENI-style credentials:
229 # Either a cred string xml string, or Credential object of a tuple
230 # [{'geni_type' : geni_type, 'geni_value : cred_value,
231 # 'geni_version' : version}]
232 # caller_gid is the raw X509 cert gid
233 # options is the dictionary of API-provided options
234 # trusted_roots is a list of Certificate objects from the system
235 # trusted_root directory
236 # Optionally, provide an XML schema against which to validate the credential
237 def determine_speaks_for(logger, credentials, caller_gid, speaking_for_xrn, trusted_roots, schema=None):
239 speaking_for_urn = Xrn (speaking_for_xrn.strip()).get_urn()
240 for cred in credentials:
241 # Skip things that aren't ABAC credentials
242 if type(cred) == dict:
243 if cred['geni_type'] != ABACCredential.ABAC_CREDENTIAL_TYPE: continue
244 cred_value = cred['geni_value']
245 elif isinstance(cred, Credential):
246 if not isinstance(cred, ABACCredential):
251 if CredentialFactory.getType(cred) != ABACCredential.ABAC_CREDENTIAL_TYPE: continue
254 # If the cred_value is xml, create the object
255 if not isinstance(cred_value, ABACCredential):
256 cred = CredentialFactory.createCred(cred_value)
258 # print "Got a cred to check speaksfor for: %s" % cred.pretty_cred()
259 # #cred.dump(True, True)
260 # print "Caller: %s" % caller_gid.dump_string(2, True)
261 # See if this is a valid speaks_for
262 is_valid_speaks_for, user_gid, msg = \
263 verify_speaks_for(cred,
264 caller_gid, speaking_for_urn, \
265 trusted_roots, schema, logger=logger)
267 if is_valid_speaks_for:
268 return user_gid # speaks-for
271 logger.info("Got speaks-for option but not a valid speaks_for with this credential: %s" % msg)
273 print "Got a speaks-for option but not a valid speaks_for with this credential: " + msg
274 return caller_gid # Not speaks-for
276 # Create an ABAC Speaks For credential using the ABACCredential object and it's encode&sign methods
277 def create_sign_abaccred(tool_gid, user_gid, ma_gid, user_key_file, cred_filename, dur_days=365):
278 print "Creating ABAC SpeaksFor using ABACCredential...\n"
279 # Write out the user cert
280 from tempfile import mkstemp
281 ma_str = ma_gid.save_to_string()
282 user_cert_str = user_gid.save_to_string()
283 if not user_cert_str.endswith(ma_str):
284 user_cert_str += ma_str
285 fp, user_cert_filename = mkstemp(suffix='cred', text=True)
286 fp = os.fdopen(fp, "w")
287 fp.write(user_cert_str)
291 cred = ABACCredential()
292 cred.set_issuer_keys(user_key_file, user_cert_filename)
293 tool_urn = tool_gid.get_urn()
294 user_urn = user_gid.get_urn()
295 user_keyid = get_cert_keyid(user_gid)
296 tool_keyid = get_cert_keyid(tool_gid)
297 cred.head = ABACElement(user_keyid, user_urn, "speaks_for_%s" % user_keyid)
298 cred.tails.append(ABACElement(tool_keyid, tool_urn))
299 cred.set_expiration(datetime.datetime.utcnow() + datetime.timedelta(days=dur_days))
300 cred.expiration = cred.expiration.replace(microsecond=0)
302 # Produce the cred XML
308 cred.save_to_file(cred_filename)
309 print "Created ABAC credential: '%s' in file %s" % \
310 (cred.pretty_cred(), cred_filename)
312 # FIXME: Assumes signer is itself signed by an 'ma_gid' that can be trusted
313 def create_speaks_for(tool_gid, user_gid, ma_gid, \
314 user_key_file, cred_filename, dur_days=365):
315 tool_urn = tool_gid.get_urn()
316 user_urn = user_gid.get_urn()
318 header = '<?xml version="1.0" encoding="UTF-8"?>'
322 signature_template + \
324 template = header + '\n' + \
325 '<signed-credential '
326 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"'
327 template += '>\n' + \
328 '<credential xml:id="%s">\n' + \
329 '<type>abac</type>\n' + \
333 '<target_gid/>\n' + \
334 '<target_urn/>\n' + \
336 '<expires>%s</expires>' +\
339 '<version>%s</version>\n' + \
341 '<ABACprincipal><keyid>%s</keyid><mnemonic>%s</mnemonic></ABACprincipal>\n' +\
342 '<role>speaks_for_%s</role>\n' + \
345 '<ABACprincipal><keyid>%s</keyid><mnemonic>%s</mnemonic></ABACprincipal>\n' +\
349 '</credential>\n' + \
351 '</signed-credential>\n'
354 credential_duration = datetime.timedelta(days=dur_days)
355 expiration = datetime.datetime.utcnow() + credential_duration
356 expiration_str = expiration.strftime(SFATIME_FORMAT)
359 user_keyid = get_cert_keyid(user_gid)
360 tool_keyid = get_cert_keyid(tool_gid)
361 unsigned_cred = template % (reference, expiration_str, version, \
362 user_keyid, user_urn, user_keyid, tool_keyid, tool_urn, \
363 reference, reference)
364 unsigned_cred_filename = write_to_tempfile(unsigned_cred)
366 # Now sign the file with xmlsec1
367 # xmlsec1 --sign --privkey-pem privkey.pem,cert.pem
368 # --output signed.xml tosign.xml
369 pems = "%s,%s,%s" % (user_key_file, user_gid.get_filename(),
370 ma_gid.get_filename())
371 xmlsec1 = cred.get_xmlsec1_path()
373 raise Exception("Could not locate required 'xmlsec1' program")
374 cmd = [ xmlsec1, '--sign', '--privkey-pem', pems,
375 '--output', cred_filename, unsigned_cred_filename]
377 # print " ".join(cmd)
378 sign_proc_output = run_subprocess(cmd, stdout=subprocess.PIPE, stderr=None)
379 if sign_proc_output == None:
380 print "OUTPUT = %s" % sign_proc_output
382 print "Created ABAC credential: '%s speaks_for %s' in file %s" % \
383 (tool_urn, user_urn, cred_filename)
384 os.unlink(unsigned_cred_filename)
388 if __name__ == "__main__":
390 parser = optparse.OptionParser()
391 parser.add_option('--cred_file',
392 help='Name of credential file')
393 parser.add_option('--tool_cert_file',
394 help='Name of file containing tool certificate')
395 parser.add_option('--user_urn',
396 help='URN of speaks-for user')
397 parser.add_option('--user_cert_file',
398 help="filename of x509 certificate of signing user")
399 parser.add_option('--ma_cert_file',
400 help="filename of x509 cert of MA that signed user cert")
401 parser.add_option('--user_key_file',
402 help="filename of private key of signing user")
403 parser.add_option('--trusted_roots_directory',
404 help='Directory of trusted root certs')
405 parser.add_option('--create',
406 help="name of file of ABAC speaksfor cred to create")
407 parser.add_option('--useObject', action='store_true', default=False,
408 help='Use the ABACCredential object to create the credential (default False)')
410 options, args = parser.parse_args(sys.argv)
412 tool_gid = GID(filename=options.tool_cert_file)
415 if options.user_cert_file and options.user_key_file \
416 and options.ma_cert_file:
417 user_gid = GID(filename=options.user_cert_file)
418 ma_gid = GID(filename=options.ma_cert_file)
419 if options.useObject:
420 create_sign_abaccred(tool_gid, user_gid, ma_gid, \
421 options.user_key_file, \
424 create_speaks_for(tool_gid, user_gid, ma_gid, \
425 options.user_key_file, \
428 print "Usage: --create cred_file " + \
429 "--user_cert_file user_cert_file" + \
430 " --user_key_file user_key_file --ma_cert_file ma_cert_file"
433 user_urn = options.user_urn
435 # Get list of trusted rootcerts
436 if options.cred_file and not options.trusted_roots_directory:
437 sys.exit("Must supply --trusted_roots_directory to validate a credential")
439 trusted_roots_directory = options.trusted_roots_directory
441 [Certificate(filename=os.path.join(trusted_roots_directory, file)) \
442 for file in os.listdir(trusted_roots_directory) \
443 if file.endswith('.pem') and file != 'CATedCACerts.pem']
445 cred = open(options.cred_file).read()
447 creds = [{'geni_type' : ABACCredential.ABAC_CREDENTIAL_TYPE, 'geni_value' : cred,
448 'geni_version' : '1'}]
449 gid = determine_speaks_for(None, creds, tool_gid, \
450 {'geni_speaking_for' : user_urn}, \
454 print 'SPEAKS_FOR = %s' % (gid != tool_gid)
455 print "CERT URN = %s" % gid.get_urn()