dos2unix'ed
[sfa.git] / sfa / trust / speaksfor_util.py
1 #----------------------------------------------------------------------
2 # Copyright (c) 2014 Raytheon BBN Technologies
3 #
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:
10 #
11 # The above copyright notice and this permission notice shall be
12 # included in all copies or substantial portions of the Work.
13 #
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
21 # IN THE WORK.
22 #----------------------------------------------------------------------
23
24 import datetime
25 from dateutil import parser as du_parser, tz as du_tz
26 import optparse
27 import os
28 import subprocess
29 import sys
30 import tempfile
31 from xml.dom.minidom import *
32 from StringIO import StringIO
33
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
39
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
43 # ABAC statement:
44 # S.speaks_for(S)<-T Or "S says that T speaks for S"
45
46 # Requires that openssl be installed and in the path
47 # create_speaks_for requires that xmlsec1 be on the path
48
49 # Simple XML helper functions
50
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)
55     return None
56
57 # Find first child with given name
58 def findChildNamed(root, name):
59     for child in root.childNodes:
60         if child.nodeName == name:
61             return child
62     return None
63
64 # Write a string to a tempfile, returning name of tempfile
65 def write_to_tempfile(str):
66     str_fd, str_file = tempfile.mkstemp()
67     if str:
68         os.write(str_fd, str)
69     os.close(str_fd)
70     return str_file
71
72 # Run a subprocess and return output
73 def run_subprocess(cmd, stdout, stderr):
74     try:
75         proc = subprocess.Popen(cmd, stdout=stdout, stderr=stderr)
76         proc.wait()
77         if stdout:
78             output = proc.stdout.read()
79         else:
80             output = proc.returncode
81         return output
82     except Exception as e:
83         raise Exception("Failed call to subprocess '%s': %s" % (" ".join(cmd), e))
84
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.
90
91     """
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()
96     return keyid
97
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)
103     else:
104         start_index = 0
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)
110     return first_cert
111
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
115 # Return: 
116 #   Boolean indicating whether the given credential 
117 #      is not expired 
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
125 #      (None otherwise)
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):
129
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())
133
134     # Must be ABAC
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
137
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()
142
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())
147
148     tails = cred.get_tails()
149     if len(tails) != 1: 
150         return False, None, "Invalid ABAC-SF credential: Need exactly 1 tail element, got %d (%s)" % \
151             (len(tails), cred.get_summary_tostring())
152
153     user_keyid = get_cert_keyid(user_gid)
154     tool_keyid = get_cert_keyid(tool_gid)
155     subject_keyid = tails[0].get_principal_keyid()
156
157     head = cred.get_head()
158     principal_keyid = head.get_principal_keyid()
159     role = head.get_role()
160
161     logger.info('user keyid: %s' % user_keyid)  
162     logger.info('principal keyid: %s' % principal_keyid)        
163     logger.info('tool keyid: %s' % tool_keyid)  
164     logger.info('subject keyid: %s' % subject_keyid) 
165     logger.info('role: %s' % role) 
166     logger.info('user gid: %s' % user_gid.dump_string())
167     f = open('/tmp/speaksfor/tool.gid', 'w')
168     f.write(tool_gid.dump_string())
169     f.close()   
170
171     # Credential must pass xmlsec1 verify
172     cred_file = write_to_tempfile(cred.save_to_string())
173     cert_args = []
174     if trusted_roots:
175         for x in trusted_roots:
176             cert_args += ['--trusted-pem', x.filename]
177     # FIXME: Why do we not need to specify the --node-id option as credential.py does?
178     xmlsec1_args = [cred.xmlsec_path, '--verify'] + cert_args + [ cred_file]
179     output = run_subprocess(xmlsec1_args, stdout=None, stderr=subprocess.PIPE)
180     os.unlink(cred_file)
181     if output != 0:
182         # FIXME
183         # xmlsec errors have a msg= which is the interesting bit.
184         # But does this go to stderr or stdout? Do we have it here?
185         mstart = verified.find("msg=")
186         msg = ""
187         if mstart > -1 and len(verified) > 4:
188             mstart = mstart + 4
189             mend = verified.find('\\', mstart)
190             msg = verified[mstart:mend]
191         if msg == "":
192             msg = output
193         return False, None, "ABAC credential failed to xmlsec1 verify: %s" % msg
194
195     # Must say U.speaks_for(U)<-T
196     if user_keyid != principal_keyid or \
197             tool_keyid != subject_keyid or \
198             role != ('speaks_for_%s' % user_keyid):
199         return False, None, "ABAC statement doesn't assert U.speaks_for(U)<-T (%s)" % cred.get_summary_tostring()
200
201     # If schema provided, validate against schema
202     if HAVELXML and schema and os.path.exists(schema):
203         from lxml import etree
204         tree = etree.parse(StringIO(cred.xml))
205         schema_doc = etree.parse(schema)
206         xmlschema = etree.XMLSchema(schema_doc)
207         if not xmlschema.validate(tree):
208             error = xmlschema.error_log.last_error
209             message = "%s: %s (line %s)" % (cred.get_summary_tostring(), error.message, error.line)
210             return False, None, ("XML Credential schema invalid: %s" % message)
211
212     if trusted_roots:
213         # User certificate must validate against trusted roots
214         try:
215             user_gid.verify_chain(trusted_roots)
216         except Exception, e:
217             return False, None, \
218                 "Cred signer (user) cert not trusted: %s" % e
219
220         # Tool certificate must validate against trusted roots
221         try:
222             tool_gid.verify_chain(trusted_roots)
223         except Exception, e:
224             return False, None, \
225                 "Tool cert not trusted: %s" % e
226
227     return True, user_gid, ""
228
229 # Determine if this is a speaks-for context. If so, validate
230 # And return either the tool_cert (not speaks-for or not validated)
231 # or the user cert (validated speaks-for)
232 #
233 # credentials is a list of GENI-style credentials:
234 #  Either a cred string xml string, or Credential object of a tuple
235 #    [{'geni_type' : geni_type, 'geni_value : cred_value, 
236 #      'geni_version' : version}]
237 # caller_gid is the raw X509 cert gid
238 # options is the dictionary of API-provided options
239 # trusted_roots is a list of Certificate objects from the system
240 #   trusted_root directory
241 # Optionally, provide an XML schema against which to validate the credential
242 def determine_speaks_for(logger, credentials, caller_gid, options,
243                          trusted_roots, schema=None):
244     logger.info(options)
245     logger.info("geni speaking for:%s " % 'geni_speaking_for' in options)  
246     if options and 'geni_speaking_for' in options:
247         speaking_for_urn = options['geni_speaking_for'].strip()
248         for cred in credentials:
249             # Skip things that aren't ABAC credentials
250             if type(cred) == dict:
251                 if cred['geni_type'] != ABACCredential.ABAC_CREDENTIAL_TYPE: continue
252                 cred_value = cred['geni_value']
253             elif isinstance(cred, Credential):
254                 if not isinstance(cred, ABACCredential):
255                     continue
256                 else:
257                     cred_value = cred
258             else:
259                 if CredentialFactory.getType(cred) != ABACCredential.ABAC_CREDENTIAL_TYPE: continue
260                 cred_value = cred
261
262             # If the cred_value is xml, create the object
263             if not isinstance(cred_value, ABACCredential):
264                 cred = CredentialFactory.createCred(cred_value)
265
266 #            print "Got a cred to check speaksfor for: %s" % cred.get_summary_tostring()
267 #            #cred.dump(True, True)
268 #            print "Caller: %s" % caller_gid.dump_string(2, True)
269             logger.info(cred.dump_string())
270             f = open('/tmp/speaksfor/%s.cred' % cred, 'w')
271             f.write(cred.xml)
272             f.close()
273             # See if this is a valid speaks_for
274             is_valid_speaks_for, user_gid, msg = \
275                 verify_speaks_for(cred,
276                                   caller_gid, speaking_for_urn, \
277                                       trusted_roots, schema, logger=logger)
278             logger.info(msg)
279             if is_valid_speaks_for:
280                 return user_gid # speaks-for
281             else:
282                 if logger:
283                     logger.info("Got speaks-for option but not a valid speaks_for with this credential: %s" % msg)
284                 else:
285                     print "Got a speaks-for option but not a valid speaks_for with this credential: " + msg
286     return caller_gid # Not speaks-for
287
288 # Create an ABAC Speaks For credential using the ABACCredential object and it's encode&sign methods
289 def create_sign_abaccred(tool_gid, user_gid, ma_gid, user_key_file, cred_filename, dur_days=365):
290     print "Creating ABAC SpeaksFor using ABACCredential...\n"
291     # Write out the user cert
292     from tempfile import mkstemp
293     ma_str = ma_gid.save_to_string()
294     user_cert_str = user_gid.save_to_string()
295     if not user_cert_str.endswith(ma_str):
296         user_cert_str += ma_str
297     fp, user_cert_filename = mkstemp(suffix='cred', text=True)
298     fp = os.fdopen(fp, "w")
299     fp.write(user_cert_str)
300     fp.close()
301
302     # Create the cred
303     cred = ABACCredential()
304     cred.set_issuer_keys(user_key_file, user_cert_filename)
305     tool_urn = tool_gid.get_urn()
306     user_urn = user_gid.get_urn()
307     user_keyid = get_cert_keyid(user_gid)
308     tool_keyid = get_cert_keyid(tool_gid)
309     cred.head = ABACElement(user_keyid, user_urn, "speaks_for_%s" % user_keyid)
310     cred.tails.append(ABACElement(tool_keyid, tool_urn))
311     cred.set_expiration(datetime.datetime.utcnow() + datetime.timedelta(days=dur_days))
312     cred.expiration = cred.expiration.replace(microsecond=0)
313
314     # Produce the cred XML
315     cred.encode()
316
317     # Sign it
318     cred.sign()
319     # Save it
320     cred.save_to_file(cred_filename)
321     print "Created ABAC credential: '%s' in file %s" % \
322             (cred.get_summary_tostring(), cred_filename)
323
324 # FIXME: Assumes xmlsec1 is on path
325 # FIXME: Assumes signer is itself signed by an 'ma_gid' that can be trusted
326 def create_speaks_for(tool_gid, user_gid, ma_gid, \
327                           user_key_file, cred_filename, dur_days=365):
328     tool_urn = tool_gid.get_urn()
329     user_urn = user_gid.get_urn()
330
331     header = '<?xml version="1.0" encoding="UTF-8"?>'
332     reference = "ref0"
333     signature_block = \
334         '<signatures>\n' + \
335         signature_template + \
336         '</signatures>'
337     template = header + '\n' + \
338         '<signed-credential '
339     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"'
340     template += '>\n' + \
341         '<credential xml:id="%s">\n' + \
342         '<type>abac</type>\n' + \
343         '<serial/>\n' +\
344         '<owner_gid/>\n' + \
345         '<owner_urn/>\n' + \
346         '<target_gid/>\n' + \
347         '<target_urn/>\n' + \
348         '<uuid/>\n' + \
349         '<expires>%s</expires>' +\
350         '<abac>\n' + \
351         '<rt0>\n' + \
352         '<version>%s</version>\n' + \
353         '<head>\n' + \
354         '<ABACprincipal><keyid>%s</keyid><mnemonic>%s</mnemonic></ABACprincipal>\n' +\
355         '<role>speaks_for_%s</role>\n' + \
356         '</head>\n' + \
357         '<tail>\n' +\
358         '<ABACprincipal><keyid>%s</keyid><mnemonic>%s</mnemonic></ABACprincipal>\n' +\
359         '</tail>\n' +\
360         '</rt0>\n' + \
361         '</abac>\n' + \
362         '</credential>\n' + \
363         signature_block + \
364         '</signed-credential>\n'
365
366
367     credential_duration = datetime.timedelta(days=dur_days)
368     expiration = datetime.datetime.now(du_tz.tzutc()) + credential_duration
369     expiration_str = expiration.strftime('%Y-%m-%dT%H:%M:%SZ') # FIXME: libabac can't handle .isoformat()
370     version = "1.1"
371
372     user_keyid = get_cert_keyid(user_gid)
373     tool_keyid = get_cert_keyid(tool_gid)
374     unsigned_cred = template % (reference, expiration_str, version, \
375                                     user_keyid, user_urn, user_keyid, tool_keyid, tool_urn, \
376                                     reference, reference)
377     unsigned_cred_filename = write_to_tempfile(unsigned_cred)
378
379     # Now sign the file with xmlsec1
380     # xmlsec1 --sign --privkey-pem privkey.pem,cert.pem 
381     # --output signed.xml tosign.xml
382     pems = "%s,%s,%s" % (user_key_file, user_gid.get_filename(),
383                          ma_gid.get_filename())
384     # FIXME: assumes xmlsec1 is on path
385     cmd = ['xmlsec1',  '--sign',  '--privkey-pem', pems, 
386            '--output', cred_filename, unsigned_cred_filename]
387
388 #    print " ".join(cmd)
389     sign_proc_output = run_subprocess(cmd, stdout=subprocess.PIPE, stderr=None)
390     if sign_proc_output == None:
391         print "OUTPUT = %s" % sign_proc_output
392     else:
393         print "Created ABAC credential: '%s speaks_for %s' in file %s" % \
394             (tool_urn, user_urn, cred_filename)
395     os.unlink(unsigned_cred_filename)
396
397
398 # Test procedure
399 if __name__ == "__main__":
400
401     parser = optparse.OptionParser()
402     parser.add_option('--cred_file', 
403                       help='Name of credential file')
404     parser.add_option('--tool_cert_file', 
405                       help='Name of file containing tool certificate')
406     parser.add_option('--user_urn', 
407                       help='URN of speaks-for user')
408     parser.add_option('--user_cert_file', 
409                       help="filename of x509 certificate of signing user")
410     parser.add_option('--ma_cert_file', 
411                       help="filename of x509 cert of MA that signed user cert")
412     parser.add_option('--user_key_file', 
413                       help="filename of private key of signing user")
414     parser.add_option('--trusted_roots_directory', 
415                       help='Directory of trusted root certs')
416     parser.add_option('--create',
417                       help="name of file of ABAC speaksfor cred to create")
418     parser.add_option('--useObject', action='store_true', default=False,
419                       help='Use the ABACCredential object to create the credential (default False)')
420
421     options, args = parser.parse_args(sys.argv)
422
423     tool_gid = GID(filename=options.tool_cert_file)
424
425     if options.create:
426         if options.user_cert_file and options.user_key_file \
427             and options.ma_cert_file:
428             user_gid = GID(filename=options.user_cert_file)
429             ma_gid = GID(filename=options.ma_cert_file)
430             if options.useObject:
431                 create_sign_abaccred(tool_gid, user_gid, ma_gid, \
432                                          options.user_key_file,  \
433                                          options.create)
434             else:
435                 create_speaks_for(tool_gid, user_gid, ma_gid, \
436                                          options.user_key_file,  \
437                                          options.create)
438         else:
439             print "Usage: --create cred_file " + \
440                 "--user_cert_file user_cert_file" + \
441                 " --user_key_file user_key_file --ma_cert_file ma_cert_file"
442         sys.exit()
443
444     user_urn = options.user_urn
445
446     # Get list of trusted rootcerts
447     if options.cred_file and not options.trusted_roots_directory:
448         sys.exit("Must supply --trusted_roots_directory to validate a credential")
449
450     trusted_roots_directory = options.trusted_roots_directory
451     trusted_roots = \
452         [Certificate(filename=os.path.join(trusted_roots_directory, file)) \
453              for file in os.listdir(trusted_roots_directory) \
454              if file.endswith('.pem') and file != 'CATedCACerts.pem']
455
456     cred = open(options.cred_file).read()
457
458     creds = [{'geni_type' : ABACCredential.ABAC_CREDENTIAL_TYPE, 'geni_value' : cred, 
459               'geni_version' : '1'}]
460     gid = determine_speaks_for(None, creds, tool_gid, \
461                                    {'geni_speaking_for' : user_urn}, \
462                                    trusted_roots)
463
464
465     print 'SPEAKS_FOR = %s' % (gid != tool_gid)
466     print "CERT URN = %s" % gid.get_urn()