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, options,
233 trusted_roots, schema=None):
234 if options and 'geni_speaking_for' in options:
235 speaking_for_urn = options['geni_speaking_for'].strip()
236 for cred in credentials:
237 # Skip things that aren't ABAC credentials
238 if type(cred) == dict:
239 if cred['geni_type'] != ABACCredential.ABAC_CREDENTIAL_TYPE: continue
240 cred_value = cred['geni_value']
241 elif isinstance(cred, Credential):
242 if not isinstance(cred, ABACCredential):
247 if CredentialFactory.getType(cred) != ABACCredential.ABAC_CREDENTIAL_TYPE: continue
250 # If the cred_value is xml, create the object
251 if not isinstance(cred_value, ABACCredential):
252 cred = CredentialFactory.createCred(cred_value)
254 # print "Got a cred to check speaksfor for: %s" % cred.get_summary_tostring()
255 # #cred.dump(True, True)
256 # print "Caller: %s" % caller_gid.dump_string(2, True)
257 # See if this is a valid speaks_for
258 is_valid_speaks_for, user_gid, msg = \
259 verify_speaks_for(cred,
260 caller_gid, speaking_for_urn, \
261 trusted_roots, schema, logger=logger)
263 if is_valid_speaks_for:
264 return user_gid # speaks-for
267 logger.info("Got speaks-for option but not a valid speaks_for with this credential: %s" % msg)
269 print "Got a speaks-for option but not a valid speaks_for with this credential: " + msg
270 return caller_gid # Not speaks-for
272 # Create an ABAC Speaks For credential using the ABACCredential object and it's encode&sign methods
273 def create_sign_abaccred(tool_gid, user_gid, ma_gid, user_key_file, cred_filename, dur_days=365):
274 print "Creating ABAC SpeaksFor using ABACCredential...\n"
275 # Write out the user cert
276 from tempfile import mkstemp
277 ma_str = ma_gid.save_to_string()
278 user_cert_str = user_gid.save_to_string()
279 if not user_cert_str.endswith(ma_str):
280 user_cert_str += ma_str
281 fp, user_cert_filename = mkstemp(suffix='cred', text=True)
282 fp = os.fdopen(fp, "w")
283 fp.write(user_cert_str)
287 cred = ABACCredential()
288 cred.set_issuer_keys(user_key_file, user_cert_filename)
289 tool_urn = tool_gid.get_urn()
290 user_urn = user_gid.get_urn()
291 user_keyid = get_cert_keyid(user_gid)
292 tool_keyid = get_cert_keyid(tool_gid)
293 cred.head = ABACElement(user_keyid, user_urn, "speaks_for_%s" % user_keyid)
294 cred.tails.append(ABACElement(tool_keyid, tool_urn))
295 cred.set_expiration(datetime.datetime.utcnow() + datetime.timedelta(days=dur_days))
296 cred.expiration = cred.expiration.replace(microsecond=0)
298 # Produce the cred XML
304 cred.save_to_file(cred_filename)
305 print "Created ABAC credential: '%s' in file %s" % \
306 (cred.get_summary_tostring(), cred_filename)
308 # FIXME: Assumes xmlsec1 is on path
309 # FIXME: Assumes signer is itself signed by an 'ma_gid' that can be trusted
310 def create_speaks_for(tool_gid, user_gid, ma_gid, \
311 user_key_file, cred_filename, dur_days=365):
312 tool_urn = tool_gid.get_urn()
313 user_urn = user_gid.get_urn()
315 header = '<?xml version="1.0" encoding="UTF-8"?>'
319 signature_template + \
321 template = header + '\n' + \
322 '<signed-credential '
323 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"'
324 template += '>\n' + \
325 '<credential xml:id="%s">\n' + \
326 '<type>abac</type>\n' + \
330 '<target_gid/>\n' + \
331 '<target_urn/>\n' + \
333 '<expires>%s</expires>' +\
336 '<version>%s</version>\n' + \
338 '<ABACprincipal><keyid>%s</keyid><mnemonic>%s</mnemonic></ABACprincipal>\n' +\
339 '<role>speaks_for_%s</role>\n' + \
342 '<ABACprincipal><keyid>%s</keyid><mnemonic>%s</mnemonic></ABACprincipal>\n' +\
346 '</credential>\n' + \
348 '</signed-credential>\n'
351 credential_duration = datetime.timedelta(days=dur_days)
352 expiration = datetime.datetime.now(du_tz.tzutc()) + credential_duration
353 expiration_str = expiration.strftime('%Y-%m-%dT%H:%M:%SZ') # FIXME: libabac can't handle .isoformat()
356 user_keyid = get_cert_keyid(user_gid)
357 tool_keyid = get_cert_keyid(tool_gid)
358 unsigned_cred = template % (reference, expiration_str, version, \
359 user_keyid, user_urn, user_keyid, tool_keyid, tool_urn, \
360 reference, reference)
361 unsigned_cred_filename = write_to_tempfile(unsigned_cred)
363 # Now sign the file with xmlsec1
364 # xmlsec1 --sign --privkey-pem privkey.pem,cert.pem
365 # --output signed.xml tosign.xml
366 pems = "%s,%s,%s" % (user_key_file, user_gid.get_filename(),
367 ma_gid.get_filename())
368 # FIXME: assumes xmlsec1 is on path
369 cmd = ['xmlsec1', '--sign', '--privkey-pem', pems,
370 '--output', cred_filename, unsigned_cred_filename]
372 # print " ".join(cmd)
373 sign_proc_output = run_subprocess(cmd, stdout=subprocess.PIPE, stderr=None)
374 if sign_proc_output == None:
375 print "OUTPUT = %s" % sign_proc_output
377 print "Created ABAC credential: '%s speaks_for %s' in file %s" % \
378 (tool_urn, user_urn, cred_filename)
379 os.unlink(unsigned_cred_filename)
383 if __name__ == "__main__":
385 parser = optparse.OptionParser()
386 parser.add_option('--cred_file',
387 help='Name of credential file')
388 parser.add_option('--tool_cert_file',
389 help='Name of file containing tool certificate')
390 parser.add_option('--user_urn',
391 help='URN of speaks-for user')
392 parser.add_option('--user_cert_file',
393 help="filename of x509 certificate of signing user")
394 parser.add_option('--ma_cert_file',
395 help="filename of x509 cert of MA that signed user cert")
396 parser.add_option('--user_key_file',
397 help="filename of private key of signing user")
398 parser.add_option('--trusted_roots_directory',
399 help='Directory of trusted root certs')
400 parser.add_option('--create',
401 help="name of file of ABAC speaksfor cred to create")
402 parser.add_option('--useObject', action='store_true', default=False,
403 help='Use the ABACCredential object to create the credential (default False)')
405 options, args = parser.parse_args(sys.argv)
407 tool_gid = GID(filename=options.tool_cert_file)
410 if options.user_cert_file and options.user_key_file \
411 and options.ma_cert_file:
412 user_gid = GID(filename=options.user_cert_file)
413 ma_gid = GID(filename=options.ma_cert_file)
414 if options.useObject:
415 create_sign_abaccred(tool_gid, user_gid, ma_gid, \
416 options.user_key_file, \
419 create_speaks_for(tool_gid, user_gid, ma_gid, \
420 options.user_key_file, \
423 print "Usage: --create cred_file " + \
424 "--user_cert_file user_cert_file" + \
425 " --user_key_file user_key_file --ma_cert_file ma_cert_file"
428 user_urn = options.user_urn
430 # Get list of trusted rootcerts
431 if options.cred_file and not options.trusted_roots_directory:
432 sys.exit("Must supply --trusted_roots_directory to validate a credential")
434 trusted_roots_directory = options.trusted_roots_directory
436 [Certificate(filename=os.path.join(trusted_roots_directory, file)) \
437 for file in os.listdir(trusted_roots_directory) \
438 if file.endswith('.pem') and file != 'CATedCACerts.pem']
440 cred = open(options.cred_file).read()
442 creds = [{'geni_type' : ABACCredential.ABAC_CREDENTIAL_TYPE, 'geni_value' : cred,
443 'geni_version' : '1'}]
444 gid = determine_speaks_for(None, creds, tool_gid, \
445 {'geni_speaking_for' : user_urn}, \
449 print 'SPEAKS_FOR = %s' % (gid != tool_gid)
450 print "CERT URN = %s" % gid.get_urn()