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.trust.certificate import Certificate
35 from sfa.trust.credential import Credential, signature_template, HAVELXML
36 from sfa.trust.abac_credential import ABACCredential, ABACElement
37 from sfa.trust.credential_factory import CredentialFactory
38 from sfa.trust.gid import GID
40 # Routine to validate that a speaks-for credential
41 # says what it claims to say:
42 # It is a signed credential wherein the signer S is attesting to the
44 # S.speaks_for(S)<-T Or "S says that T speaks for S"
46 # Requires that openssl be installed and in the path
47 # create_speaks_for requires that xmlsec1 be on the path
49 # Simple XML helper functions
51 # Find the text associated with first child text node
52 def findTextChildValue(root):
53 child = findChildNamed(root, '#text')
54 if child: return str(child.nodeValue)
57 # Find first child with given name
58 def findChildNamed(root, name):
59 for child in root.childNodes:
60 if child.nodeName == name:
64 # Write a string to a tempfile, returning name of tempfile
65 def write_to_tempfile(str):
66 str_fd, str_file = tempfile.mkstemp()
72 # Run a subprocess and return output
73 def run_subprocess(cmd, stdout, stderr):
75 proc = subprocess.Popen(cmd, stdout=stdout, stderr=stderr)
78 output = proc.stdout.read()
80 output = proc.returncode
82 except Exception as e:
83 raise Exception("Failed call to subprocess '%s': %s" % (" ".join(cmd), e))
85 def get_cert_keyid(gid):
86 """Extract the subject key identifier from the given certificate.
87 Return they key id as lowercase string with no colon separators
88 between pairs. The key id as shown in the text output of a
89 certificate are in uppercase with colon separators.
92 raw_key_id = gid.get_extension('subjectKeyIdentifier')
93 # Raw has colons separating pairs, and all characters are upper case.
94 # Remove the colons and convert to lower case.
95 keyid = raw_key_id.replace(':', '').lower()
98 # Pull the cert out of a list of certs in a PEM formatted cert string
99 def grab_toplevel_cert(cert):
100 start_label = '-----BEGIN CERTIFICATE-----'
101 if cert.find(start_label) > -1:
102 start_index = cert.find(start_label) + len(start_label)
105 end_label = '-----END CERTIFICATE-----'
106 end_index = cert.find(end_label)
107 first_cert = cert[start_index:end_index]
108 pieces = first_cert.split('\n')
109 first_cert = "".join(pieces)
112 # Validate that the given speaks-for credential represents the
113 # statement User.speaks_for(User)<-Tool for the given user and tool certs
114 # and was signed by the user
116 # Boolean indicating whether the given credential
118 # is an ABAC credential
119 # was signed by the user associated with the speaking_for_urn
120 # is verified by xmlsec1
121 # asserts U.speaks_for(U)<-T ("user says that T may speak for user")
122 # If schema provided, validate against schema
123 # is trusted by given set of trusted roots (both user cert and tool cert)
124 # String user certificate of speaking_for user if the above tests succeed
126 # Error message indicating why the speaks_for call failed ("" otherwise)
127 def verify_speaks_for(cred, tool_gid, speaking_for_urn,
128 trusted_roots, schema=None, logger=None):
130 # Credential has not expired
131 if cred.expiration and cred.expiration < datetime.datetime.utcnow():
132 return False, None, "ABAC Credential expired at %s (%s)" % (cred.expiration.isoformat(), cred.get_summary_tostring())
135 if cred.get_cred_type() != ABACCredential.ABAC_CREDENTIAL_TYPE:
136 return False, None, "Credential not of type ABAC but %s" % cred.get_cred_type
138 if cred.signature is None or cred.signature.gid is None:
139 return False, None, "Credential malformed: missing signature or signer cert. Cred: %s" % cred.get_summary_tostring()
140 user_gid = cred.signature.gid
141 user_urn = user_gid.get_urn()
143 # URN of signer from cert must match URN of 'speaking-for' argument
144 if user_urn != speaking_for_urn:
145 return False, None, "User URN from cred doesn't match speaking_for URN: %s != %s (cred %s)" % \
146 (user_urn, speaking_for_urn, cred.get_summary_tostring())
148 tails = cred.get_tails()
150 return False, None, "Invalid ABAC-SF credential: Need exactly 1 tail element, got %d (%s)" % \
151 (len(tails), cred.get_summary_tostring())
153 user_keyid = get_cert_keyid(user_gid)
154 tool_keyid = get_cert_keyid(tool_gid)
155 subject_keyid = tails[0].get_principal_keyid()
157 head = cred.get_head()
158 principal_keyid = head.get_principal_keyid()
159 role = head.get_role()
161 # Credential must pass xmlsec1 verify
162 cred_file = write_to_tempfile(cred.save_to_string())
165 for x in trusted_roots:
166 cert_args += ['--trusted-pem', x.filename]
167 # FIXME: Why do we not need to specify the --node-id option as credential.py does?
168 xmlsec1_args = [cred.xmlsec_path, '--verify'] + cert_args + [ cred_file]
169 output = run_subprocess(xmlsec1_args, stdout=None, stderr=subprocess.PIPE)
173 # xmlsec errors have a msg= which is the interesting bit.
174 # But does this go to stderr or stdout? Do we have it here?
175 mstart = verified.find("msg=")
177 if mstart > -1 and len(verified) > 4:
179 mend = verified.find('\\', mstart)
180 msg = verified[mstart:mend]
183 return False, None, "ABAC credential failed to xmlsec1 verify: %s" % msg
185 # Must say U.speaks_for(U)<-T
186 if user_keyid != principal_keyid or \
187 tool_keyid != subject_keyid or \
188 role != ('speaks_for_%s' % user_keyid):
189 return False, None, "ABAC statement doesn't assert U.speaks_for(U)<-T (%s)" % cred.get_summary_tostring()
191 # If schema provided, validate against schema
192 if HAVELXML and schema and os.path.exists(schema):
193 from lxml import etree
194 tree = etree.parse(StringIO(cred.xml))
195 schema_doc = etree.parse(schema)
196 xmlschema = etree.XMLSchema(schema_doc)
197 if not xmlschema.validate(tree):
198 error = xmlschema.error_log.last_error
199 message = "%s: %s (line %s)" % (cred.get_summary_tostring(), error.message, error.line)
200 return False, None, ("XML Credential schema invalid: %s" % message)
203 # User certificate must validate against trusted roots
205 user_gid.verify_chain(trusted_roots)
207 return False, None, \
208 "Cred signer (user) cert not trusted: %s" % e
210 # Tool certificate must validate against trusted roots
212 tool_gid.verify_chain(trusted_roots)
214 return False, None, \
215 "Tool cert not trusted: %s" % e
217 return True, user_gid, ""
219 # Determine if this is a speaks-for context. If so, validate
220 # And return either the tool_cert (not speaks-for or not validated)
221 # or the user cert (validated speaks-for)
223 # credentials is a list of GENI-style credentials:
224 # Either a cred string xml string, or Credential object of a tuple
225 # [{'geni_type' : geni_type, 'geni_value : cred_value,
226 # 'geni_version' : version}]
227 # caller_gid is the raw X509 cert gid
228 # options is the dictionary of API-provided options
229 # trusted_roots is a list of Certificate objects from the system
230 # trusted_root directory
231 # Optionally, provide an XML schema against which to validate the credential
232 def determine_speaks_for(logger, credentials, caller_gid, speaking_for_xrn, trusted_roots, schema=None):
234 speaking_for_urn = Xrn (speaking_for_xrn.strip()).get_urn()
235 for cred in credentials:
236 # Skip things that aren't ABAC credentials
237 if type(cred) == dict:
238 if cred['geni_type'] != ABACCredential.ABAC_CREDENTIAL_TYPE: continue
239 cred_value = cred['geni_value']
240 elif isinstance(cred, Credential):
241 if not isinstance(cred, ABACCredential):
246 if CredentialFactory.getType(cred) != ABACCredential.ABAC_CREDENTIAL_TYPE: continue
249 # If the cred_value is xml, create the object
250 if not isinstance(cred_value, ABACCredential):
251 cred = CredentialFactory.createCred(cred_value)
253 # print "Got a cred to check speaksfor for: %s" % cred.get_summary_tostring()
254 # #cred.dump(True, True)
255 # print "Caller: %s" % caller_gid.dump_string(2, True)
256 # See if this is a valid speaks_for
257 is_valid_speaks_for, user_gid, msg = \
258 verify_speaks_for(cred,
259 caller_gid, speaking_for_urn, \
260 trusted_roots, schema, logger=logger)
262 if is_valid_speaks_for:
263 return user_gid # speaks-for
266 logger.info("Got speaks-for option but not a valid speaks_for with this credential: %s" % msg)
268 print "Got a speaks-for option but not a valid speaks_for with this credential: " + msg
269 return caller_gid # Not speaks-for
271 # Create an ABAC Speaks For credential using the ABACCredential object and it's encode&sign methods
272 def create_sign_abaccred(tool_gid, user_gid, ma_gid, user_key_file, cred_filename, dur_days=365):
273 print "Creating ABAC SpeaksFor using ABACCredential...\n"
274 # Write out the user cert
275 from tempfile import mkstemp
276 ma_str = ma_gid.save_to_string()
277 user_cert_str = user_gid.save_to_string()
278 if not user_cert_str.endswith(ma_str):
279 user_cert_str += ma_str
280 fp, user_cert_filename = mkstemp(suffix='cred', text=True)
281 fp = os.fdopen(fp, "w")
282 fp.write(user_cert_str)
286 cred = ABACCredential()
287 cred.set_issuer_keys(user_key_file, user_cert_filename)
288 tool_urn = tool_gid.get_urn()
289 user_urn = user_gid.get_urn()
290 user_keyid = get_cert_keyid(user_gid)
291 tool_keyid = get_cert_keyid(tool_gid)
292 cred.head = ABACElement(user_keyid, user_urn, "speaks_for_%s" % user_keyid)
293 cred.tails.append(ABACElement(tool_keyid, tool_urn))
294 cred.set_expiration(datetime.datetime.utcnow() + datetime.timedelta(days=dur_days))
295 cred.expiration = cred.expiration.replace(microsecond=0)
297 # Produce the cred XML
303 cred.save_to_file(cred_filename)
304 print "Created ABAC credential: '%s' in file %s" % \
305 (cred.get_summary_tostring(), cred_filename)
307 # FIXME: Assumes xmlsec1 is on path
308 # FIXME: Assumes signer is itself signed by an 'ma_gid' that can be trusted
309 def create_speaks_for(tool_gid, user_gid, ma_gid, \
310 user_key_file, cred_filename, dur_days=365):
311 tool_urn = tool_gid.get_urn()
312 user_urn = user_gid.get_urn()
314 header = '<?xml version="1.0" encoding="UTF-8"?>'
318 signature_template + \
320 template = header + '\n' + \
321 '<signed-credential '
322 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"'
323 template += '>\n' + \
324 '<credential xml:id="%s">\n' + \
325 '<type>abac</type>\n' + \
329 '<target_gid/>\n' + \
330 '<target_urn/>\n' + \
332 '<expires>%s</expires>' +\
335 '<version>%s</version>\n' + \
337 '<ABACprincipal><keyid>%s</keyid><mnemonic>%s</mnemonic></ABACprincipal>\n' +\
338 '<role>speaks_for_%s</role>\n' + \
341 '<ABACprincipal><keyid>%s</keyid><mnemonic>%s</mnemonic></ABACprincipal>\n' +\
345 '</credential>\n' + \
347 '</signed-credential>\n'
350 credential_duration = datetime.timedelta(days=dur_days)
351 expiration = datetime.datetime.utcnow() + credential_duration
352 expiration_str = expiration.strftime('%Y-%m-%dT%H:%M:%SZ') # FIXME: libabac can't handle .isoformat()
355 user_keyid = get_cert_keyid(user_gid)
356 tool_keyid = get_cert_keyid(tool_gid)
357 unsigned_cred = template % (reference, expiration_str, version, \
358 user_keyid, user_urn, user_keyid, tool_keyid, tool_urn, \
359 reference, reference)
360 unsigned_cred_filename = write_to_tempfile(unsigned_cred)
362 # Now sign the file with xmlsec1
363 # xmlsec1 --sign --privkey-pem privkey.pem,cert.pem
364 # --output signed.xml tosign.xml
365 pems = "%s,%s,%s" % (user_key_file, user_gid.get_filename(),
366 ma_gid.get_filename())
367 # FIXME: assumes xmlsec1 is on path
368 cmd = ['xmlsec1', '--sign', '--privkey-pem', pems,
369 '--output', cred_filename, unsigned_cred_filename]
371 # print " ".join(cmd)
372 sign_proc_output = run_subprocess(cmd, stdout=subprocess.PIPE, stderr=None)
373 if sign_proc_output == None:
374 print "OUTPUT = %s" % sign_proc_output
376 print "Created ABAC credential: '%s speaks_for %s' in file %s" % \
377 (tool_urn, user_urn, cred_filename)
378 os.unlink(unsigned_cred_filename)
382 if __name__ == "__main__":
384 parser = optparse.OptionParser()
385 parser.add_option('--cred_file',
386 help='Name of credential file')
387 parser.add_option('--tool_cert_file',
388 help='Name of file containing tool certificate')
389 parser.add_option('--user_urn',
390 help='URN of speaks-for user')
391 parser.add_option('--user_cert_file',
392 help="filename of x509 certificate of signing user")
393 parser.add_option('--ma_cert_file',
394 help="filename of x509 cert of MA that signed user cert")
395 parser.add_option('--user_key_file',
396 help="filename of private key of signing user")
397 parser.add_option('--trusted_roots_directory',
398 help='Directory of trusted root certs')
399 parser.add_option('--create',
400 help="name of file of ABAC speaksfor cred to create")
401 parser.add_option('--useObject', action='store_true', default=False,
402 help='Use the ABACCredential object to create the credential (default False)')
404 options, args = parser.parse_args(sys.argv)
406 tool_gid = GID(filename=options.tool_cert_file)
409 if options.user_cert_file and options.user_key_file \
410 and options.ma_cert_file:
411 user_gid = GID(filename=options.user_cert_file)
412 ma_gid = GID(filename=options.ma_cert_file)
413 if options.useObject:
414 create_sign_abaccred(tool_gid, user_gid, ma_gid, \
415 options.user_key_file, \
418 create_speaks_for(tool_gid, user_gid, ma_gid, \
419 options.user_key_file, \
422 print "Usage: --create cred_file " + \
423 "--user_cert_file user_cert_file" + \
424 " --user_key_file user_key_file --ma_cert_file ma_cert_file"
427 user_urn = options.user_urn
429 # Get list of trusted rootcerts
430 if options.cred_file and not options.trusted_roots_directory:
431 sys.exit("Must supply --trusted_roots_directory to validate a credential")
433 trusted_roots_directory = options.trusted_roots_directory
435 [Certificate(filename=os.path.join(trusted_roots_directory, file)) \
436 for file in os.listdir(trusted_roots_directory) \
437 if file.endswith('.pem') and file != 'CATedCACerts.pem']
439 cred = open(options.cred_file).read()
441 creds = [{'geni_type' : ABACCredential.ABAC_CREDENTIAL_TYPE, 'geni_value' : cred,
442 'geni_version' : '1'}]
443 gid = determine_speaks_for(None, creds, tool_gid, \
444 {'geni_speaking_for' : user_urn}, \
448 print 'SPEAKS_FOR = %s' % (gid != tool_gid)
449 print "CERT URN = %s" % gid.get_urn()