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 #----------------------------------------------------------------------
24 from __future__ import print_function
27 from dateutil import parser as du_parser, tz as du_tz
33 from xml.dom.minidom import *
34 from StringIO import StringIO
36 from sfa.util.sfatime import SFATIME_FORMAT
38 from sfa.trust.certificate import Certificate
39 from sfa.trust.credential import Credential, signature_template, HAVELXML
40 from sfa.trust.abac_credential import ABACCredential, ABACElement
41 from sfa.trust.credential_factory import CredentialFactory
42 from sfa.trust.gid import GID
43 from sfa.util.sfalogging import logger
45 # Routine to validate that a speaks-for credential
46 # says what it claims to say:
47 # It is a signed credential wherein the signer S is attesting to the
49 # S.speaks_for(S)<-T Or "S says that T speaks for S"
51 # Requires that openssl be installed and in the path
52 # create_speaks_for requires that xmlsec1 be on the path
54 # Simple XML helper functions
56 # Find the text associated with first child text node
57 def findTextChildValue(root):
58 child = findChildNamed(root, '#text')
59 if child: return str(child.nodeValue)
62 # Find first child with given name
63 def findChildNamed(root, name):
64 for child in root.childNodes:
65 if child.nodeName == name:
69 # Write a string to a tempfile, returning name of tempfile
70 def write_to_tempfile(str):
71 str_fd, str_file = tempfile.mkstemp()
77 # Run a subprocess and return output
78 def run_subprocess(cmd, stdout, stderr):
80 proc = subprocess.Popen(cmd, stdout=stdout, stderr=stderr)
83 output = proc.stdout.read()
85 output = proc.returncode
87 except Exception as e:
88 raise Exception("Failed call to subprocess '%s': %s" % (" ".join(cmd), e))
90 def get_cert_keyid(gid):
91 """Extract the subject key identifier from the given certificate.
92 Return they key id as lowercase string with no colon separators
93 between pairs. The key id as shown in the text output of a
94 certificate are in uppercase with colon separators.
97 raw_key_id = gid.get_extension('subjectKeyIdentifier')
98 # Raw has colons separating pairs, and all characters are upper case.
99 # Remove the colons and convert to lower case.
100 keyid = raw_key_id.replace(':', '').lower()
103 # Pull the cert out of a list of certs in a PEM formatted cert string
104 def grab_toplevel_cert(cert):
105 start_label = '-----BEGIN CERTIFICATE-----'
106 if cert.find(start_label) > -1:
107 start_index = cert.find(start_label) + len(start_label)
110 end_label = '-----END CERTIFICATE-----'
111 end_index = cert.find(end_label)
112 first_cert = cert[start_index:end_index]
113 pieces = first_cert.split('\n')
114 first_cert = "".join(pieces)
117 # Validate that the given speaks-for credential represents the
118 # statement User.speaks_for(User)<-Tool for the given user and tool certs
119 # and was signed by the user
121 # Boolean indicating whether the given credential
123 # is an ABAC credential
124 # was signed by the user associated with the speaking_for_urn
125 # is verified by xmlsec1
126 # asserts U.speaks_for(U)<-T ("user says that T may speak for user")
127 # If schema provided, validate against schema
128 # is trusted by given set of trusted roots (both user cert and tool cert)
129 # String user certificate of speaking_for user if the above tests succeed
131 # Error message indicating why the speaks_for call failed ("" otherwise)
132 def verify_speaks_for(cred, tool_gid, speaking_for_urn,
133 trusted_roots, schema=None, logger=None):
135 # Credential has not expired
136 if cred.expiration and cred.expiration < datetime.datetime.utcnow():
137 return False, None, "ABAC Credential expired at %s (%s)" % (cred.expiration.strftime(SFATIME_FORMAT), cred.pretty_cred())
140 if cred.get_cred_type() != ABACCredential.ABAC_CREDENTIAL_TYPE:
141 return False, None, "Credential not of type ABAC but %s" % cred.get_cred_type
143 if cred.signature is None or cred.signature.gid is None:
144 return False, None, "Credential malformed: missing signature or signer cert. Cred: %s" % cred.pretty_cred()
145 user_gid = cred.signature.gid
146 user_urn = user_gid.get_urn()
148 # URN of signer from cert must match URN of 'speaking-for' argument
149 if user_urn != speaking_for_urn:
150 return False, None, "User URN from cred doesn't match speaking_for URN: %s != %s (cred %s)" % \
151 (user_urn, speaking_for_urn, cred.pretty_cred())
153 tails = cred.get_tails()
155 return False, None, "Invalid ABAC-SF credential: Need exactly 1 tail element, got %d (%s)" % \
156 (len(tails), cred.pretty_cred())
158 user_keyid = get_cert_keyid(user_gid)
159 tool_keyid = get_cert_keyid(tool_gid)
160 subject_keyid = tails[0].get_principal_keyid()
162 head = cred.get_head()
163 principal_keyid = head.get_principal_keyid()
164 role = head.get_role()
166 # Credential must pass xmlsec1 verify
167 cred_file = write_to_tempfile(cred.save_to_string())
170 for x in trusted_roots:
171 cert_args += ['--trusted-pem', x.filename]
172 # FIXME: Why do we not need to specify the --node-id option as credential.py does?
173 xmlsec1 = cred.get_xmlsec1_path()
175 raise Exception("Could not locate required 'xmlsec1' program")
176 xmlsec1_args = [xmlsec1, '--verify'] + cert_args + [ cred_file]
177 output = run_subprocess(xmlsec1_args, stdout=None, stderr=subprocess.PIPE)
181 # xmlsec errors have a msg= which is the interesting bit.
182 # But does this go to stderr or stdout? Do we have it here?
183 mstart = verified.find("msg=")
185 if mstart > -1 and len(verified) > 4:
187 mend = verified.find('\\', mstart)
188 msg = verified[mstart:mend]
191 return False, None, "ABAC credential failed to xmlsec1 verify: %s" % msg
193 # Must say U.speaks_for(U)<-T
194 if user_keyid != principal_keyid or \
195 tool_keyid != subject_keyid or \
196 role != ('speaks_for_%s' % user_keyid):
197 return False, None, "ABAC statement doesn't assert U.speaks_for(U)<-T (%s)" % cred.pretty_cred()
199 # If schema provided, validate against schema
200 if HAVELXML and schema and os.path.exists(schema):
201 from lxml import etree
202 tree = etree.parse(StringIO(cred.xml))
203 schema_doc = etree.parse(schema)
204 xmlschema = etree.XMLSchema(schema_doc)
205 if not xmlschema.validate(tree):
206 error = xmlschema.error_log.last_error
207 message = "%s: %s (line %s)" % (cred.pretty_cred(), error.message, error.line)
208 return False, None, ("XML Credential schema invalid: %s" % message)
211 # User certificate must validate against trusted roots
213 user_gid.verify_chain(trusted_roots)
215 return False, None, \
216 "Cred signer (user) cert not trusted: %s" % e
218 # Tool certificate must validate against trusted roots
220 tool_gid.verify_chain(trusted_roots)
222 return False, None, \
223 "Tool cert not trusted: %s" % e
225 return True, user_gid, ""
227 # Determine if this is a speaks-for context. If so, validate
228 # And return either the tool_cert (not speaks-for or not validated)
229 # or the user cert (validated speaks-for)
231 # credentials is a list of GENI-style credentials:
232 # Either a cred string xml string, or Credential object of a tuple
233 # [{'geni_type' : geni_type, 'geni_value : cred_value,
234 # 'geni_version' : version}]
235 # caller_gid is the raw X509 cert gid
236 # options is the dictionary of API-provided options
237 # trusted_roots is a list of Certificate objects from the system
238 # trusted_root directory
239 # Optionally, provide an XML schema against which to validate the credential
240 def determine_speaks_for(logger, credentials, caller_gid, speaking_for_xrn, trusted_roots, schema=None):
242 speaking_for_urn = Xrn (speaking_for_xrn.strip()).get_urn()
243 for cred in credentials:
244 # Skip things that aren't ABAC credentials
245 if type(cred) == dict:
246 if cred['geni_type'] != ABACCredential.ABAC_CREDENTIAL_TYPE: continue
247 cred_value = cred['geni_value']
248 elif isinstance(cred, Credential):
249 if not isinstance(cred, ABACCredential):
254 if CredentialFactory.getType(cred) != ABACCredential.ABAC_CREDENTIAL_TYPE: continue
257 # If the cred_value is xml, create the object
258 if not isinstance(cred_value, ABACCredential):
259 cred = CredentialFactory.createCred(cred_value)
261 # print("Got a cred to check speaksfor for: %s" % cred.pretty_cred())
262 # #cred.dump(True, True)
263 # print("Caller: %s" % caller_gid.dump_string(2, True))
264 # See if this is a valid speaks_for
265 is_valid_speaks_for, user_gid, msg = \
266 verify_speaks_for(cred,
267 caller_gid, speaking_for_urn,
268 trusted_roots, schema, logger=logger)
270 if is_valid_speaks_for:
271 return user_gid # speaks-for
273 logger.info("Got speaks-for option but not a valid speaks_for with this credential: %s" % 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 logger.info("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 logger.info("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 = Credential.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 logger.info("xmlsec1 returns empty output")
382 logger.info("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())