83178225bc52aacd8de42c32f66cdf458e06eee8
[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 from __future__ import print_function
25
26 import datetime
27 from dateutil import parser as du_parser, tz as du_tz
28 import optparse
29 import os
30 import subprocess
31 import sys
32 import tempfile
33 from xml.dom.minidom import *
34
35 from sfa.util.sfatime import SFATIME_FORMAT
36
37 from sfa.trust.certificate import Certificate
38 from sfa.trust.credential import Credential, signature_template, HAVELXML
39 from sfa.trust.abac_credential import ABACCredential, ABACElement
40 from sfa.trust.credential_factory import CredentialFactory
41 from sfa.trust.gid import GID
42 from sfa.util.sfalogging import logger
43 from sfa.util.py23 import StringIO
44
45 # Routine to validate that a speaks-for credential 
46 # says what it claims to say:
47 # It is a signed credential wherein the signer S is attesting to the
48 # ABAC statement:
49 # S.speaks_for(S)<-T Or "S says that T speaks for S"
50
51 # Requires that openssl be installed and in the path
52 # create_speaks_for requires that xmlsec1 be on the path
53
54 # Simple XML helper functions
55
56 # Find the text associated with first child text node
57 def findTextChildValue(root):
58     child = findChildNamed(root, '#text')
59     if child: return str(child.nodeValue)
60     return None
61
62 # Find first child with given name
63 def findChildNamed(root, name):
64     for child in root.childNodes:
65         if child.nodeName == name:
66             return child
67     return None
68
69 # Write a string to a tempfile, returning name of tempfile
70 def write_to_tempfile(str):
71     str_fd, str_file = tempfile.mkstemp()
72     if str:
73         os.write(str_fd, str)
74     os.close(str_fd)
75     return str_file
76
77 # Run a subprocess and return output
78 def run_subprocess(cmd, stdout, stderr):
79     try:
80         proc = subprocess.Popen(cmd, stdout=stdout, stderr=stderr)
81         proc.wait()
82         if stdout:
83             output = proc.stdout.read()
84         else:
85             output = proc.returncode
86         return output
87     except Exception as e:
88         raise Exception("Failed call to subprocess '%s': %s" % (" ".join(cmd), e))
89
90 def get_cert_keyid(gid):
91     """Extract the subject key identifier from the given certificate.
92     Return they key id as lowercase string with no colon separators
93     between pairs. The key id as shown in the text output of a
94     certificate are in uppercase with colon separators.
95
96     """
97     raw_key_id = gid.get_extension('subjectKeyIdentifier')
98     # Raw has colons separating pairs, and all characters are upper case.
99     # Remove the colons and convert to lower case.
100     keyid = raw_key_id.replace(':', '').lower()
101     return keyid
102
103 # Pull the cert out of a list of certs in a PEM formatted cert string
104 def grab_toplevel_cert(cert):
105     start_label = '-----BEGIN CERTIFICATE-----'
106     if cert.find(start_label) > -1:
107         start_index = cert.find(start_label) + len(start_label)
108     else:
109         start_index = 0
110     end_label = '-----END CERTIFICATE-----'
111     end_index = cert.find(end_label)
112     first_cert = cert[start_index:end_index]
113     pieces = first_cert.split('\n')
114     first_cert = "".join(pieces)
115     return first_cert
116
117 # Validate that the given speaks-for credential represents the
118 # statement User.speaks_for(User)<-Tool for the given user and tool certs
119 # and was signed by the user
120 # Return: 
121 #   Boolean indicating whether the given credential 
122 #      is not expired 
123 #      is an ABAC credential
124 #      was signed by the user associated with the speaking_for_urn
125 #      is verified by xmlsec1
126 #      asserts U.speaks_for(U)<-T ("user says that T may speak for user")
127 #      If schema provided, validate against schema
128 #      is trusted by given set of trusted roots (both user cert and tool cert)
129 #   String user certificate of speaking_for user if the above tests succeed
130 #      (None otherwise)
131 #   Error message indicating why the speaks_for call failed ("" otherwise)
132 def verify_speaks_for(cred, tool_gid, speaking_for_urn,
133                       trusted_roots, schema=None, logger=None):
134
135     # Credential has not expired
136     if cred.expiration and cred.expiration < datetime.datetime.utcnow():
137         return False, None, "ABAC Credential expired at %s (%s)" % (cred.expiration.strftime(SFATIME_FORMAT), cred.pretty_cred())
138
139     # Must be ABAC
140     if cred.get_cred_type() != ABACCredential.ABAC_CREDENTIAL_TYPE:
141         return False, None, "Credential not of type ABAC but %s" % cred.get_cred_type
142
143     if cred.signature is None or cred.signature.gid is None:
144         return False, None, "Credential malformed: missing signature or signer cert. Cred: %s" % cred.pretty_cred()
145     user_gid = cred.signature.gid
146     user_urn = user_gid.get_urn()
147
148     # URN of signer from cert must match URN of 'speaking-for' argument
149     if user_urn != speaking_for_urn:
150         return False, None, "User URN from cred doesn't match speaking_for URN: %s != %s (cred %s)" % \
151             (user_urn, speaking_for_urn, cred.pretty_cred())
152
153     tails = cred.get_tails()
154     if len(tails) != 1: 
155         return False, None, "Invalid ABAC-SF credential: Need exactly 1 tail element, got %d (%s)" % \
156             (len(tails), cred.pretty_cred())
157
158     user_keyid = get_cert_keyid(user_gid)
159     tool_keyid = get_cert_keyid(tool_gid)
160     subject_keyid = tails[0].get_principal_keyid()
161
162     head = cred.get_head()
163     principal_keyid = head.get_principal_keyid()
164     role = head.get_role()
165
166     # Credential must pass xmlsec1 verify
167     cred_file = write_to_tempfile(cred.save_to_string())
168     cert_args = []
169     if trusted_roots:
170         for x in trusted_roots:
171             cert_args += ['--trusted-pem', x.filename]
172     # FIXME: Why do we not need to specify the --node-id option as credential.py does?
173     xmlsec1 = cred.get_xmlsec1_path()
174     if not xmlsec1:
175         raise Exception("Could not locate required 'xmlsec1' program")
176     xmlsec1_args = [xmlsec1, '--verify'] + cert_args + [ cred_file]
177     output = run_subprocess(xmlsec1_args, stdout=None, stderr=subprocess.PIPE)
178     os.unlink(cred_file)
179     if output != 0:
180         # FIXME
181         # xmlsec errors have a msg= which is the interesting bit.
182         # But does this go to stderr or stdout? Do we have it here?
183         mstart = verified.find("msg=")
184         msg = ""
185         if mstart > -1 and len(verified) > 4:
186             mstart = mstart + 4
187             mend = verified.find('\\', mstart)
188             msg = verified[mstart:mend]
189         if msg == "":
190             msg = output
191         return False, None, "ABAC credential failed to xmlsec1 verify: %s" % msg
192
193     # Must say U.speaks_for(U)<-T
194     if user_keyid != principal_keyid or \
195             tool_keyid != subject_keyid or \
196             role != ('speaks_for_%s' % user_keyid):
197         return False, None, "ABAC statement doesn't assert U.speaks_for(U)<-T (%s)" % cred.pretty_cred()
198
199     # If schema provided, validate against schema
200     if HAVELXML and schema and os.path.exists(schema):
201         from lxml import etree
202         tree = etree.parse(StringIO(cred.xml))
203         schema_doc = etree.parse(schema)
204         xmlschema = etree.XMLSchema(schema_doc)
205         if not xmlschema.validate(tree):
206             error = xmlschema.error_log.last_error
207             message = "%s: %s (line %s)" % (cred.pretty_cred(), error.message, error.line)
208             return False, None, ("XML Credential schema invalid: %s" % message)
209
210     if trusted_roots:
211         # User certificate must validate against trusted roots
212         try:
213             user_gid.verify_chain(trusted_roots)
214         except Exception as e:
215             return False, None, \
216                 "Cred signer (user) cert not trusted: %s" % e
217
218         # Tool certificate must validate against trusted roots
219         try:
220             tool_gid.verify_chain(trusted_roots)
221         except Exception as e:
222             return False, None, \
223                 "Tool cert not trusted: %s" % e
224
225     return True, user_gid, ""
226
227 # Determine if this is a speaks-for context. If so, validate
228 # And return either the tool_cert (not speaks-for or not validated)
229 # or the user cert (validated speaks-for)
230 #
231 # credentials is a list of GENI-style credentials:
232 #  Either a cred string xml string, or Credential object of a tuple
233 #    [{'geni_type' : geni_type, 'geni_value : cred_value, 
234 #      'geni_version' : version}]
235 # caller_gid is the raw X509 cert gid
236 # options is the dictionary of API-provided options
237 # trusted_roots is a list of Certificate objects from the system
238 #   trusted_root directory
239 # Optionally, provide an XML schema against which to validate the credential
240 def determine_speaks_for(logger, credentials, caller_gid, speaking_for_xrn, trusted_roots, schema=None):
241     if speaking_for_xrn:
242         speaking_for_urn = Xrn (speaking_for_xrn.strip()).get_urn()
243         for cred in credentials:
244             # Skip things that aren't ABAC credentials
245             if type(cred) == dict:
246                 if cred['geni_type'] != ABACCredential.ABAC_CREDENTIAL_TYPE: continue
247                 cred_value = cred['geni_value']
248             elif isinstance(cred, Credential):
249                 if not isinstance(cred, ABACCredential):
250                     continue
251                 else:
252                     cred_value = cred
253             else:
254                 if CredentialFactory.getType(cred) != ABACCredential.ABAC_CREDENTIAL_TYPE: continue
255                 cred_value = cred
256
257             # If the cred_value is xml, create the object
258             if not isinstance(cred_value, ABACCredential):
259                 cred = CredentialFactory.createCred(cred_value)
260
261 #            print("Got a cred to check speaksfor for: %s" % cred.pretty_cred())
262 #            #cred.dump(True, True)
263 #            print("Caller: %s" % caller_gid.dump_string(2, True))
264             # See if this is a valid speaks_for
265             is_valid_speaks_for, user_gid, msg = \
266                 verify_speaks_for(cred,
267                                   caller_gid, speaking_for_urn,
268                                   trusted_roots, schema, logger=logger)
269             logger.info(msg)
270             if is_valid_speaks_for:
271                 return user_gid # speaks-for
272             else:
273                 logger.info("Got speaks-for option but not a valid speaks_for with this credential: %s" % 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     logger.info("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     logger.info("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 = Credential.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         logger.info("xmlsec1 returns empty output")
381     else:
382         logger.info("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())