more aggressively check for xmlsec1 being installed,
[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.pretty_cred())
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.pretty_cred()
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.pretty_cred())
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.pretty_cred())
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 = cred.get_xmlsec1_path()
171     if not xmlsec1:
172         raise Exception("Could not locate required 'xmlsec1' program")
173     xmlsec1_args = [xmlsec1, '--verify'] + cert_args + [ cred_file]
174     output = run_subprocess(xmlsec1_args, stdout=None, stderr=subprocess.PIPE)
175     os.unlink(cred_file)
176     if output != 0:
177         # FIXME
178         # xmlsec errors have a msg= which is the interesting bit.
179         # But does this go to stderr or stdout? Do we have it here?
180         mstart = verified.find("msg=")
181         msg = ""
182         if mstart > -1 and len(verified) > 4:
183             mstart = mstart + 4
184             mend = verified.find('\\', mstart)
185             msg = verified[mstart:mend]
186         if msg == "":
187             msg = output
188         return False, None, "ABAC credential failed to xmlsec1 verify: %s" % msg
189
190     # Must say U.speaks_for(U)<-T
191     if user_keyid != principal_keyid or \
192             tool_keyid != subject_keyid or \
193             role != ('speaks_for_%s' % user_keyid):
194         return False, None, "ABAC statement doesn't assert U.speaks_for(U)<-T (%s)" % cred.pretty_cred()
195
196     # If schema provided, validate against schema
197     if HAVELXML and schema and os.path.exists(schema):
198         from lxml import etree
199         tree = etree.parse(StringIO(cred.xml))
200         schema_doc = etree.parse(schema)
201         xmlschema = etree.XMLSchema(schema_doc)
202         if not xmlschema.validate(tree):
203             error = xmlschema.error_log.last_error
204             message = "%s: %s (line %s)" % (cred.pretty_cred(), error.message, error.line)
205             return False, None, ("XML Credential schema invalid: %s" % message)
206
207     if trusted_roots:
208         # User certificate must validate against trusted roots
209         try:
210             user_gid.verify_chain(trusted_roots)
211         except Exception, e:
212             return False, None, \
213                 "Cred signer (user) cert not trusted: %s" % e
214
215         # Tool certificate must validate against trusted roots
216         try:
217             tool_gid.verify_chain(trusted_roots)
218         except Exception, e:
219             return False, None, \
220                 "Tool cert not trusted: %s" % e
221
222     return True, user_gid, ""
223
224 # Determine if this is a speaks-for context. If so, validate
225 # And return either the tool_cert (not speaks-for or not validated)
226 # or the user cert (validated speaks-for)
227 #
228 # credentials is a list of GENI-style credentials:
229 #  Either a cred string xml string, or Credential object of a tuple
230 #    [{'geni_type' : geni_type, 'geni_value : cred_value, 
231 #      'geni_version' : version}]
232 # caller_gid is the raw X509 cert gid
233 # options is the dictionary of API-provided options
234 # trusted_roots is a list of Certificate objects from the system
235 #   trusted_root directory
236 # Optionally, provide an XML schema against which to validate the credential
237 def determine_speaks_for(logger, credentials, caller_gid, speaking_for_xrn, trusted_roots, schema=None):
238     if speaking_for_xrn:
239         speaking_for_urn = Xrn (speaking_for_xrn.strip()).get_urn()
240         for cred in credentials:
241             # Skip things that aren't ABAC credentials
242             if type(cred) == dict:
243                 if cred['geni_type'] != ABACCredential.ABAC_CREDENTIAL_TYPE: continue
244                 cred_value = cred['geni_value']
245             elif isinstance(cred, Credential):
246                 if not isinstance(cred, ABACCredential):
247                     continue
248                 else:
249                     cred_value = cred
250             else:
251                 if CredentialFactory.getType(cred) != ABACCredential.ABAC_CREDENTIAL_TYPE: continue
252                 cred_value = cred
253
254             # If the cred_value is xml, create the object
255             if not isinstance(cred_value, ABACCredential):
256                 cred = CredentialFactory.createCred(cred_value)
257
258 #            print "Got a cred to check speaksfor for: %s" % cred.pretty_cred()
259 #            #cred.dump(True, True)
260 #            print "Caller: %s" % caller_gid.dump_string(2, True)
261             # See if this is a valid speaks_for
262             is_valid_speaks_for, user_gid, msg = \
263                 verify_speaks_for(cred,
264                                   caller_gid, speaking_for_urn, \
265                                       trusted_roots, schema, logger=logger)
266             logger.info(msg)
267             if is_valid_speaks_for:
268                 return user_gid # speaks-for
269             else:
270                 if logger:
271                     logger.info("Got speaks-for option but not a valid speaks_for with this credential: %s" % msg)
272                 else:
273                     print "Got a speaks-for option but not a valid speaks_for with this credential: " + msg
274     return caller_gid # Not speaks-for
275
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     print "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)
288     fp.close()
289
290     # Create the cred
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)
301
302     # Produce the cred XML
303     cred.encode()
304
305     # Sign it
306     cred.sign()
307     # Save it
308     cred.save_to_file(cred_filename)
309     print "Created ABAC credential: '%s' in file %s" % \
310             (cred.pretty_cred(), cred_filename)
311
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()
317
318     header = '<?xml version="1.0" encoding="UTF-8"?>'
319     reference = "ref0"
320     signature_block = \
321         '<signatures>\n' + \
322         signature_template + \
323         '</signatures>'
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' + \
330         '<serial/>\n' +\
331         '<owner_gid/>\n' + \
332         '<owner_urn/>\n' + \
333         '<target_gid/>\n' + \
334         '<target_urn/>\n' + \
335         '<uuid/>\n' + \
336         '<expires>%s</expires>' +\
337         '<abac>\n' + \
338         '<rt0>\n' + \
339         '<version>%s</version>\n' + \
340         '<head>\n' + \
341         '<ABACprincipal><keyid>%s</keyid><mnemonic>%s</mnemonic></ABACprincipal>\n' +\
342         '<role>speaks_for_%s</role>\n' + \
343         '</head>\n' + \
344         '<tail>\n' +\
345         '<ABACprincipal><keyid>%s</keyid><mnemonic>%s</mnemonic></ABACprincipal>\n' +\
346         '</tail>\n' +\
347         '</rt0>\n' + \
348         '</abac>\n' + \
349         '</credential>\n' + \
350         signature_block + \
351         '</signed-credential>\n'
352
353
354     credential_duration = datetime.timedelta(days=dur_days)
355     expiration = datetime.datetime.utcnow() + credential_duration
356     expiration_str = expiration.strftime(SFATIME_FORMAT)
357     version = "1.1"
358
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)
365
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 = cred.get_xmlsec1_path()
372     if not xmlsec1:
373         raise Exception("Could not locate required 'xmlsec1' program")
374     cmd = [ xmlsec1,  '--sign',  '--privkey-pem', pems, 
375            '--output', cred_filename, unsigned_cred_filename]
376
377 #    print " ".join(cmd)
378     sign_proc_output = run_subprocess(cmd, stdout=subprocess.PIPE, stderr=None)
379     if sign_proc_output == None:
380         print "OUTPUT = %s" % sign_proc_output
381     else:
382         print "Created ABAC credential: '%s speaks_for %s' in file %s" % \
383             (tool_urn, user_urn, cred_filename)
384     os.unlink(unsigned_cred_filename)
385
386
387 # Test procedure
388 if __name__ == "__main__":
389
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)')
409
410     options, args = parser.parse_args(sys.argv)
411
412     tool_gid = GID(filename=options.tool_cert_file)
413
414     if options.create:
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,  \
422                                          options.create)
423             else:
424                 create_speaks_for(tool_gid, user_gid, ma_gid, \
425                                          options.user_key_file,  \
426                                          options.create)
427         else:
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"
431         sys.exit()
432
433     user_urn = options.user_urn
434
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")
438
439     trusted_roots_directory = options.trusted_roots_directory
440     trusted_roots = \
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']
444
445     cred = open(options.cred_file).read()
446
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}, \
451                                    trusted_roots)
452
453
454     print 'SPEAKS_FOR = %s' % (gid != tool_gid)
455     print "CERT URN = %s" % gid.get_urn()