always using utcnow on datetime objects
[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     # Credential must pass xmlsec1 verify
162     cred_file = write_to_tempfile(cred.save_to_string())
163     cert_args = []
164     if trusted_roots:
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)
170     os.unlink(cred_file)
171     if output != 0:
172         # FIXME
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=")
176         msg = ""
177         if mstart > -1 and len(verified) > 4:
178             mstart = mstart + 4
179             mend = verified.find('\\', mstart)
180             msg = verified[mstart:mend]
181         if msg == "":
182             msg = output
183         return False, None, "ABAC credential failed to xmlsec1 verify: %s" % msg
184
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()
190
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)
201
202     if trusted_roots:
203         # User certificate must validate against trusted roots
204         try:
205             user_gid.verify_chain(trusted_roots)
206         except Exception, e:
207             return False, None, \
208                 "Cred signer (user) cert not trusted: %s" % e
209
210         # Tool certificate must validate against trusted roots
211         try:
212             tool_gid.verify_chain(trusted_roots)
213         except Exception, e:
214             return False, None, \
215                 "Tool cert not trusted: %s" % e
216
217     return True, user_gid, ""
218
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)
222 #
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):
233     if speaking_for_xrn:
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):
242                     continue
243                 else:
244                     cred_value = cred
245             else:
246                 if CredentialFactory.getType(cred) != ABACCredential.ABAC_CREDENTIAL_TYPE: continue
247                 cred_value = cred
248
249             # If the cred_value is xml, create the object
250             if not isinstance(cred_value, ABACCredential):
251                 cred = CredentialFactory.createCred(cred_value)
252
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)
261             logger.info(msg)
262             if is_valid_speaks_for:
263                 return user_gid # speaks-for
264             else:
265                 if logger:
266                     logger.info("Got speaks-for option but not a valid speaks_for with this credential: %s" % msg)
267                 else:
268                     print "Got a speaks-for option but not a valid speaks_for with this credential: " + msg
269     return caller_gid # Not speaks-for
270
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)
283     fp.close()
284
285     # Create the cred
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)
296
297     # Produce the cred XML
298     cred.encode()
299
300     # Sign it
301     cred.sign()
302     # Save it
303     cred.save_to_file(cred_filename)
304     print "Created ABAC credential: '%s' in file %s" % \
305             (cred.get_summary_tostring(), cred_filename)
306
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()
313
314     header = '<?xml version="1.0" encoding="UTF-8"?>'
315     reference = "ref0"
316     signature_block = \
317         '<signatures>\n' + \
318         signature_template + \
319         '</signatures>'
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' + \
326         '<serial/>\n' +\
327         '<owner_gid/>\n' + \
328         '<owner_urn/>\n' + \
329         '<target_gid/>\n' + \
330         '<target_urn/>\n' + \
331         '<uuid/>\n' + \
332         '<expires>%s</expires>' +\
333         '<abac>\n' + \
334         '<rt0>\n' + \
335         '<version>%s</version>\n' + \
336         '<head>\n' + \
337         '<ABACprincipal><keyid>%s</keyid><mnemonic>%s</mnemonic></ABACprincipal>\n' +\
338         '<role>speaks_for_%s</role>\n' + \
339         '</head>\n' + \
340         '<tail>\n' +\
341         '<ABACprincipal><keyid>%s</keyid><mnemonic>%s</mnemonic></ABACprincipal>\n' +\
342         '</tail>\n' +\
343         '</rt0>\n' + \
344         '</abac>\n' + \
345         '</credential>\n' + \
346         signature_block + \
347         '</signed-credential>\n'
348
349
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()
353     version = "1.1"
354
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)
361
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]
370
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
375     else:
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)
379
380
381 # Test procedure
382 if __name__ == "__main__":
383
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)')
403
404     options, args = parser.parse_args(sys.argv)
405
406     tool_gid = GID(filename=options.tool_cert_file)
407
408     if options.create:
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,  \
416                                          options.create)
417             else:
418                 create_speaks_for(tool_gid, user_gid, ma_gid, \
419                                          options.user_key_file,  \
420                                          options.create)
421         else:
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"
425         sys.exit()
426
427     user_urn = options.user_urn
428
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")
432
433     trusted_roots_directory = options.trusted_roots_directory
434     trusted_roots = \
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']
438
439     cred = open(options.cred_file).read()
440
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}, \
445                                    trusted_roots)
446
447
448     print 'SPEAKS_FOR = %s' % (gid != tool_gid)
449     print "CERT URN = %s" % gid.get_urn()