remove debug logging
[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, 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):
243                     continue
244                 else:
245                     cred_value = cred
246             else:
247                 if CredentialFactory.getType(cred) != ABACCredential.ABAC_CREDENTIAL_TYPE: continue
248                 cred_value = cred
249
250             # If the cred_value is xml, create the object
251             if not isinstance(cred_value, ABACCredential):
252                 cred = CredentialFactory.createCred(cred_value)
253
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)
262             logger.info(msg)
263             if is_valid_speaks_for:
264                 return user_gid # speaks-for
265             else:
266                 if logger:
267                     logger.info("Got speaks-for option but not a valid speaks_for with this credential: %s" % msg)
268                 else:
269                     print "Got a speaks-for option but not a valid speaks_for with this credential: " + msg
270     return caller_gid # Not speaks-for
271
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)
284     fp.close()
285
286     # Create the cred
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)
297
298     # Produce the cred XML
299     cred.encode()
300
301     # Sign it
302     cred.sign()
303     # Save it
304     cred.save_to_file(cred_filename)
305     print "Created ABAC credential: '%s' in file %s" % \
306             (cred.get_summary_tostring(), cred_filename)
307
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()
314
315     header = '<?xml version="1.0" encoding="UTF-8"?>'
316     reference = "ref0"
317     signature_block = \
318         '<signatures>\n' + \
319         signature_template + \
320         '</signatures>'
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' + \
327         '<serial/>\n' +\
328         '<owner_gid/>\n' + \
329         '<owner_urn/>\n' + \
330         '<target_gid/>\n' + \
331         '<target_urn/>\n' + \
332         '<uuid/>\n' + \
333         '<expires>%s</expires>' +\
334         '<abac>\n' + \
335         '<rt0>\n' + \
336         '<version>%s</version>\n' + \
337         '<head>\n' + \
338         '<ABACprincipal><keyid>%s</keyid><mnemonic>%s</mnemonic></ABACprincipal>\n' +\
339         '<role>speaks_for_%s</role>\n' + \
340         '</head>\n' + \
341         '<tail>\n' +\
342         '<ABACprincipal><keyid>%s</keyid><mnemonic>%s</mnemonic></ABACprincipal>\n' +\
343         '</tail>\n' +\
344         '</rt0>\n' + \
345         '</abac>\n' + \
346         '</credential>\n' + \
347         signature_block + \
348         '</signed-credential>\n'
349
350
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()
354     version = "1.1"
355
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)
362
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]
371
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
376     else:
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)
380
381
382 # Test procedure
383 if __name__ == "__main__":
384
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)')
404
405     options, args = parser.parse_args(sys.argv)
406
407     tool_gid = GID(filename=options.tool_cert_file)
408
409     if options.create:
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,  \
417                                          options.create)
418             else:
419                 create_speaks_for(tool_gid, user_gid, ma_gid, \
420                                          options.user_key_file,  \
421                                          options.create)
422         else:
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"
426         sys.exit()
427
428     user_urn = options.user_urn
429
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")
433
434     trusted_roots_directory = options.trusted_roots_directory
435     trusted_roots = \
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']
439
440     cred = open(options.cred_file).read()
441
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}, \
446                                    trusted_roots)
447
448
449     print 'SPEAKS_FOR = %s' % (gid != tool_gid)
450     print "CERT URN = %s" % gid.get_urn()