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