remove debug logging
[sfa.git] / sfa / trust / speaksfor_util.py
1 #----------------------------------------------------------------------\r
2 # Copyright (c) 2014 Raytheon BBN Technologies\r
3 #\r
4 # Permission is hereby granted, free of charge, to any person obtaining\r
5 # a copy of this software and/or hardware specification (the "Work") to\r
6 # deal in the Work without restriction, including without limitation the\r
7 # rights to use, copy, modify, merge, publish, distribute, sublicense,\r
8 # and/or sell copies of the Work, and to permit persons to whom the Work\r
9 # is furnished to do so, subject to the following conditions:\r
10 #\r
11 # The above copyright notice and this permission notice shall be\r
12 # included in all copies or substantial portions of the Work.\r
13 #\r
14 # THE WORK IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS\r
15 # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\r
16 # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\r
17 # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT\r
18 # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,\r
19 # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\r
20 # OUT OF OR IN CONNECTION WITH THE WORK OR THE USE OR OTHER DEALINGS\r
21 # IN THE WORK.\r
22 #----------------------------------------------------------------------\r
23 \r
24 import datetime\r
25 from dateutil import parser as du_parser, tz as du_tz\r
26 import optparse\r
27 import os\r
28 import subprocess\r
29 import sys\r
30 import tempfile\r
31 from xml.dom.minidom import *\r
32 from StringIO import StringIO\r
33 \r
34 from sfa.trust.certificate import Certificate\r
35 from sfa.trust.credential import Credential, signature_template, HAVELXML\r
36 from sfa.trust.abac_credential import ABACCredential, ABACElement\r
37 from sfa.trust.credential_factory import CredentialFactory\r
38 from sfa.trust.gid import GID\r
39 \r
40 # Routine to validate that a speaks-for credential \r
41 # says what it claims to say:\r
42 # It is a signed credential wherein the signer S is attesting to the\r
43 # ABAC statement:\r
44 # S.speaks_for(S)<-T Or "S says that T speaks for S"\r
45 \r
46 # Requires that openssl be installed and in the path\r
47 # create_speaks_for requires that xmlsec1 be on the path\r
48 \r
49 # Simple XML helper functions\r
50 \r
51 # Find the text associated with first child text node\r
52 def findTextChildValue(root):\r
53     child = findChildNamed(root, '#text')\r
54     if child: return str(child.nodeValue)\r
55     return None\r
56 \r
57 # Find first child with given name\r
58 def findChildNamed(root, name):\r
59     for child in root.childNodes:\r
60         if child.nodeName == name:\r
61             return child\r
62     return None\r
63 \r
64 # Write a string to a tempfile, returning name of tempfile\r
65 def write_to_tempfile(str):\r
66     str_fd, str_file = tempfile.mkstemp()\r
67     if str:\r
68         os.write(str_fd, str)\r
69     os.close(str_fd)\r
70     return str_file\r
71 \r
72 # Run a subprocess and return output\r
73 def run_subprocess(cmd, stdout, stderr):\r
74     try:\r
75         proc = subprocess.Popen(cmd, stdout=stdout, stderr=stderr)\r
76         proc.wait()\r
77         if stdout:\r
78             output = proc.stdout.read()\r
79         else:\r
80             output = proc.returncode\r
81         return output\r
82     except Exception as e:\r
83         raise Exception("Failed call to subprocess '%s': %s" % (" ".join(cmd), e))\r
84 \r
85 def get_cert_keyid(gid):\r
86     """Extract the subject key identifier from the given certificate.\r
87     Return they key id as lowercase string with no colon separators\r
88     between pairs. The key id as shown in the text output of a\r
89     certificate are in uppercase with colon separators.\r
90 \r
91     """\r
92     raw_key_id = gid.get_extension('subjectKeyIdentifier')\r
93     # Raw has colons separating pairs, and all characters are upper case.\r
94     # Remove the colons and convert to lower case.\r
95     keyid = raw_key_id.replace(':', '').lower()\r
96     return keyid\r
97 \r
98 # Pull the cert out of a list of certs in a PEM formatted cert string\r
99 def grab_toplevel_cert(cert):\r
100     start_label = '-----BEGIN CERTIFICATE-----'\r
101     if cert.find(start_label) > -1:\r
102         start_index = cert.find(start_label) + len(start_label)\r
103     else:\r
104         start_index = 0\r
105     end_label = '-----END CERTIFICATE-----'\r
106     end_index = cert.find(end_label)\r
107     first_cert = cert[start_index:end_index]\r
108     pieces = first_cert.split('\n')\r
109     first_cert = "".join(pieces)\r
110     return first_cert\r
111 \r
112 # Validate that the given speaks-for credential represents the\r
113 # statement User.speaks_for(User)<-Tool for the given user and tool certs\r
114 # and was signed by the user\r
115 # Return: \r
116 #   Boolean indicating whether the given credential \r
117 #      is not expired \r
118 #      is an ABAC credential\r
119 #      was signed by the user associated with the speaking_for_urn\r
120 #      is verified by xmlsec1\r
121 #      asserts U.speaks_for(U)<-T ("user says that T may speak for user")\r
122 #      If schema provided, validate against schema\r
123 #      is trusted by given set of trusted roots (both user cert and tool cert)\r
124 #   String user certificate of speaking_for user if the above tests succeed\r
125 #      (None otherwise)\r
126 #   Error message indicating why the speaks_for call failed ("" otherwise)\r
127 def verify_speaks_for(cred, tool_gid, speaking_for_urn, \\r
128                           trusted_roots, schema=None, logger=None):\r
129 \r
130     # Credential has not expired\r
131     if cred.expiration and cred.expiration < datetime.datetime.utcnow():\r
132         return False, None, "ABAC Credential expired at %s (%s)" % (cred.expiration.isoformat(), cred.get_summary_tostring())\r
133 \r
134     # Must be ABAC\r
135     if cred.get_cred_type() != ABACCredential.ABAC_CREDENTIAL_TYPE:\r
136         return False, None, "Credential not of type ABAC but %s" % cred.get_cred_type\r
137 \r
138     if cred.signature is None or cred.signature.gid is None:\r
139         return False, None, "Credential malformed: missing signature or signer cert. Cred: %s" % cred.get_summary_tostring()\r
140     user_gid = cred.signature.gid\r
141     user_urn = user_gid.get_urn()\r
142 \r
143     # URN of signer from cert must match URN of 'speaking-for' argument\r
144     if user_urn != speaking_for_urn:\r
145         return False, None, "User URN from cred doesn't match speaking_for URN: %s != %s (cred %s)" % \\r
146             (user_urn, speaking_for_urn, cred.get_summary_tostring())\r
147 \r
148     tails = cred.get_tails()\r
149     if len(tails) != 1: \r
150         return False, None, "Invalid ABAC-SF credential: Need exactly 1 tail element, got %d (%s)" % \\r
151             (len(tails), cred.get_summary_tostring())\r
152 \r
153     user_keyid = get_cert_keyid(user_gid)\r
154     tool_keyid = get_cert_keyid(tool_gid)\r
155     subject_keyid = tails[0].get_principal_keyid()\r
156 \r
157     head = cred.get_head()\r
158     principal_keyid = head.get_principal_keyid()\r
159     role = head.get_role()\r
160 \r
161     # Credential must pass xmlsec1 verify\r
162     cred_file = write_to_tempfile(cred.save_to_string())\r
163     cert_args = []\r
164     if trusted_roots:\r
165         for x in trusted_roots:\r
166             cert_args += ['--trusted-pem', x.filename]\r
167     # FIXME: Why do we not need to specify the --node-id option as credential.py does?\r
168     xmlsec1_args = [cred.xmlsec_path, '--verify'] + cert_args + [ cred_file]\r
169     output = run_subprocess(xmlsec1_args, stdout=None, stderr=subprocess.PIPE)\r
170     os.unlink(cred_file)\r
171     if output != 0:\r
172         # FIXME\r
173         # xmlsec errors have a msg= which is the interesting bit.\r
174         # But does this go to stderr or stdout? Do we have it here?\r
175         mstart = verified.find("msg=")\r
176         msg = ""\r
177         if mstart > -1 and len(verified) > 4:\r
178             mstart = mstart + 4\r
179             mend = verified.find('\\', mstart)\r
180             msg = verified[mstart:mend]\r
181         if msg == "":\r
182             msg = output\r
183         return False, None, "ABAC credential failed to xmlsec1 verify: %s" % msg\r
184 \r
185     # Must say U.speaks_for(U)<-T\r
186     if user_keyid != principal_keyid or \\r
187             tool_keyid != subject_keyid or \\r
188             role != ('speaks_for_%s' % user_keyid):\r
189         return False, None, "ABAC statement doesn't assert U.speaks_for(U)<-T (%s)" % cred.get_summary_tostring()\r
190 \r
191     # If schema provided, validate against schema\r
192     if HAVELXML and schema and os.path.exists(schema):\r
193         from lxml import etree\r
194         tree = etree.parse(StringIO(cred.xml))\r
195         schema_doc = etree.parse(schema)\r
196         xmlschema = etree.XMLSchema(schema_doc)\r
197         if not xmlschema.validate(tree):\r
198             error = xmlschema.error_log.last_error\r
199             message = "%s: %s (line %s)" % (cred.get_summary_tostring(), error.message, error.line)\r
200             return False, None, ("XML Credential schema invalid: %s" % message)\r
201 \r
202     if trusted_roots:\r
203         # User certificate must validate against trusted roots\r
204         try:\r
205             user_gid.verify_chain(trusted_roots)\r
206         except Exception, e:\r
207             return False, None, \\r
208                 "Cred signer (user) cert not trusted: %s" % e\r
209 \r
210         # Tool certificate must validate against trusted roots\r
211         try:\r
212             tool_gid.verify_chain(trusted_roots)\r
213         except Exception, e:\r
214             return False, None, \\r
215                 "Tool cert not trusted: %s" % e\r
216 \r
217     return True, user_gid, ""\r
218 \r
219 # Determine if this is a speaks-for context. If so, validate\r
220 # And return either the tool_cert (not speaks-for or not validated)\r
221 # or the user cert (validated speaks-for)\r
222 #\r
223 # credentials is a list of GENI-style credentials:\r
224 #  Either a cred string xml string, or Credential object of a tuple\r
225 #    [{'geni_type' : geni_type, 'geni_value : cred_value, \r
226 #      'geni_version' : version}]\r
227 # caller_gid is the raw X509 cert gid\r
228 # options is the dictionary of API-provided options\r
229 # trusted_roots is a list of Certificate objects from the system\r
230 #   trusted_root directory\r
231 # Optionally, provide an XML schema against which to validate the credential\r
232 def determine_speaks_for(logger, credentials, caller_gid, options, \\r
233                              trusted_roots, schema=None):\r
234     if options and 'geni_speaking_for' in options:\r
235         speaking_for_urn = options['geni_speaking_for'].strip()\r
236         for cred in credentials:\r
237             # Skip things that aren't ABAC credentials\r
238             if type(cred) == dict:\r
239                 if cred['geni_type'] != ABACCredential.ABAC_CREDENTIAL_TYPE: continue\r
240                 cred_value = cred['geni_value']\r
241             elif isinstance(cred, Credential):\r
242                 if not isinstance(cred, ABACCredential):\r
243                     continue\r
244                 else:\r
245                     cred_value = cred\r
246             else:\r
247                 if CredentialFactory.getType(cred) != ABACCredential.ABAC_CREDENTIAL_TYPE: continue\r
248                 cred_value = cred\r
249 \r
250             # If the cred_value is xml, create the object\r
251             if not isinstance(cred_value, ABACCredential):\r
252                 cred = CredentialFactory.createCred(cred_value)\r
253 \r
254 #            print "Got a cred to check speaksfor for: %s" % cred.get_summary_tostring()\r
255 #            #cred.dump(True, True)\r
256 #            print "Caller: %s" % caller_gid.dump_string(2, True)\r
257             # See if this is a valid speaks_for\r
258             is_valid_speaks_for, user_gid, msg = \\r
259                 verify_speaks_for(cred,\r
260                                   caller_gid, speaking_for_urn, \\r
261                                       trusted_roots, schema, logger=logger)\r
262             logger.info(msg)\r
263             if is_valid_speaks_for:\r
264                 return user_gid # speaks-for\r
265             else:\r
266                 if logger:\r
267                     logger.info("Got speaks-for option but not a valid speaks_for with this credential: %s" % msg)\r
268                 else:\r
269                     print "Got a speaks-for option but not a valid speaks_for with this credential: " + msg\r
270     return caller_gid # Not speaks-for\r
271 \r
272 # Create an ABAC Speaks For credential using the ABACCredential object and it's encode&sign methods\r
273 def create_sign_abaccred(tool_gid, user_gid, ma_gid, user_key_file, cred_filename, dur_days=365):\r
274     print "Creating ABAC SpeaksFor using ABACCredential...\n"\r
275     # Write out the user cert\r
276     from tempfile import mkstemp\r
277     ma_str = ma_gid.save_to_string()\r
278     user_cert_str = user_gid.save_to_string()\r
279     if not user_cert_str.endswith(ma_str):\r
280         user_cert_str += ma_str\r
281     fp, user_cert_filename = mkstemp(suffix='cred', text=True)\r
282     fp = os.fdopen(fp, "w")\r
283     fp.write(user_cert_str)\r
284     fp.close()\r
285 \r
286     # Create the cred\r
287     cred = ABACCredential()\r
288     cred.set_issuer_keys(user_key_file, user_cert_filename)\r
289     tool_urn = tool_gid.get_urn()\r
290     user_urn = user_gid.get_urn()\r
291     user_keyid = get_cert_keyid(user_gid)\r
292     tool_keyid = get_cert_keyid(tool_gid)\r
293     cred.head = ABACElement(user_keyid, user_urn, "speaks_for_%s" % user_keyid)\r
294     cred.tails.append(ABACElement(tool_keyid, tool_urn))\r
295     cred.set_expiration(datetime.datetime.utcnow() + datetime.timedelta(days=dur_days))\r
296     cred.expiration = cred.expiration.replace(microsecond=0)\r
297 \r
298     # Produce the cred XML\r
299     cred.encode()\r
300 \r
301     # Sign it\r
302     cred.sign()\r
303     # Save it\r
304     cred.save_to_file(cred_filename)\r
305     print "Created ABAC credential: '%s' in file %s" % \\r
306             (cred.get_summary_tostring(), cred_filename)\r
307 \r
308 # FIXME: Assumes xmlsec1 is on path\r
309 # FIXME: Assumes signer is itself signed by an 'ma_gid' that can be trusted\r
310 def create_speaks_for(tool_gid, user_gid, ma_gid, \\r
311                           user_key_file, cred_filename, dur_days=365):\r
312     tool_urn = tool_gid.get_urn()\r
313     user_urn = user_gid.get_urn()\r
314 \r
315     header = '<?xml version="1.0" encoding="UTF-8"?>'\r
316     reference = "ref0"\r
317     signature_block = \\r
318         '<signatures>\n' + \\r
319         signature_template + \\r
320         '</signatures>'\r
321     template = header + '\n' + \\r
322         '<signed-credential '\r
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"'\r
324     template += '>\n' + \\r
325         '<credential xml:id="%s">\n' + \\r
326         '<type>abac</type>\n' + \\r
327         '<serial/>\n' +\\r
328         '<owner_gid/>\n' + \\r
329         '<owner_urn/>\n' + \\r
330         '<target_gid/>\n' + \\r
331         '<target_urn/>\n' + \\r
332         '<uuid/>\n' + \\r
333         '<expires>%s</expires>' +\\r
334         '<abac>\n' + \\r
335         '<rt0>\n' + \\r
336         '<version>%s</version>\n' + \\r
337         '<head>\n' + \\r
338         '<ABACprincipal><keyid>%s</keyid><mnemonic>%s</mnemonic></ABACprincipal>\n' +\\r
339         '<role>speaks_for_%s</role>\n' + \\r
340         '</head>\n' + \\r
341         '<tail>\n' +\\r
342         '<ABACprincipal><keyid>%s</keyid><mnemonic>%s</mnemonic></ABACprincipal>\n' +\\r
343         '</tail>\n' +\\r
344         '</rt0>\n' + \\r
345         '</abac>\n' + \\r
346         '</credential>\n' + \\r
347         signature_block + \\r
348         '</signed-credential>\n'\r
349 \r
350 \r
351     credential_duration = datetime.timedelta(days=dur_days)\r
352     expiration = datetime.datetime.now(du_tz.tzutc()) + credential_duration\r
353     expiration_str = expiration.strftime('%Y-%m-%dT%H:%M:%SZ') # FIXME: libabac can't handle .isoformat()\r
354     version = "1.1"\r
355 \r
356     user_keyid = get_cert_keyid(user_gid)\r
357     tool_keyid = get_cert_keyid(tool_gid)\r
358     unsigned_cred = template % (reference, expiration_str, version, \\r
359                                     user_keyid, user_urn, user_keyid, tool_keyid, tool_urn, \\r
360                                     reference, reference)\r
361     unsigned_cred_filename = write_to_tempfile(unsigned_cred)\r
362 \r
363     # Now sign the file with xmlsec1\r
364     # xmlsec1 --sign --privkey-pem privkey.pem,cert.pem \r
365     # --output signed.xml tosign.xml\r
366     pems = "%s,%s,%s" % (user_key_file, user_gid.get_filename(),\r
367                          ma_gid.get_filename())\r
368     # FIXME: assumes xmlsec1 is on path\r
369     cmd = ['xmlsec1',  '--sign',  '--privkey-pem', pems, \r
370            '--output', cred_filename, unsigned_cred_filename]\r
371 \r
372 #    print " ".join(cmd)\r
373     sign_proc_output = run_subprocess(cmd, stdout=subprocess.PIPE, stderr=None)\r
374     if sign_proc_output == None:\r
375         print "OUTPUT = %s" % sign_proc_output\r
376     else:\r
377         print "Created ABAC credential: '%s speaks_for %s' in file %s" % \\r
378             (tool_urn, user_urn, cred_filename)\r
379     os.unlink(unsigned_cred_filename)\r
380 \r
381 \r
382 # Test procedure\r
383 if __name__ == "__main__":\r
384 \r
385     parser = optparse.OptionParser()\r
386     parser.add_option('--cred_file', \r
387                       help='Name of credential file')\r
388     parser.add_option('--tool_cert_file', \r
389                       help='Name of file containing tool certificate')\r
390     parser.add_option('--user_urn', \r
391                       help='URN of speaks-for user')\r
392     parser.add_option('--user_cert_file', \r
393                       help="filename of x509 certificate of signing user")\r
394     parser.add_option('--ma_cert_file', \r
395                       help="filename of x509 cert of MA that signed user cert")\r
396     parser.add_option('--user_key_file', \r
397                       help="filename of private key of signing user")\r
398     parser.add_option('--trusted_roots_directory', \r
399                       help='Directory of trusted root certs')\r
400     parser.add_option('--create',\r
401                       help="name of file of ABAC speaksfor cred to create")\r
402     parser.add_option('--useObject', action='store_true', default=False,\r
403                       help='Use the ABACCredential object to create the credential (default False)')\r
404 \r
405     options, args = parser.parse_args(sys.argv)\r
406 \r
407     tool_gid = GID(filename=options.tool_cert_file)\r
408 \r
409     if options.create:\r
410         if options.user_cert_file and options.user_key_file \\r
411             and options.ma_cert_file:\r
412             user_gid = GID(filename=options.user_cert_file)\r
413             ma_gid = GID(filename=options.ma_cert_file)\r
414             if options.useObject:\r
415                 create_sign_abaccred(tool_gid, user_gid, ma_gid, \\r
416                                          options.user_key_file,  \\r
417                                          options.create)\r
418             else:\r
419                 create_speaks_for(tool_gid, user_gid, ma_gid, \\r
420                                          options.user_key_file,  \\r
421                                          options.create)\r
422         else:\r
423             print "Usage: --create cred_file " + \\r
424                 "--user_cert_file user_cert_file" + \\r
425                 " --user_key_file user_key_file --ma_cert_file ma_cert_file"\r
426         sys.exit()\r
427 \r
428     user_urn = options.user_urn\r
429 \r
430     # Get list of trusted rootcerts\r
431     if options.cred_file and not options.trusted_roots_directory:\r
432         sys.exit("Must supply --trusted_roots_directory to validate a credential")\r
433 \r
434     trusted_roots_directory = options.trusted_roots_directory\r
435     trusted_roots = \\r
436         [Certificate(filename=os.path.join(trusted_roots_directory, file)) \\r
437              for file in os.listdir(trusted_roots_directory) \\r
438              if file.endswith('.pem') and file != 'CATedCACerts.pem']\r
439 \r
440     cred = open(options.cred_file).read()\r
441 \r
442     creds = [{'geni_type' : ABACCredential.ABAC_CREDENTIAL_TYPE, 'geni_value' : cred, \r
443               'geni_version' : '1'}]\r
444     gid = determine_speaks_for(None, creds, tool_gid, \\r
445                                    {'geni_speaking_for' : user_urn}, \\r
446                                    trusted_roots)\r
447 \r
448 \r
449     print 'SPEAKS_FOR = %s' % (gid != tool_gid)\r
450     print "CERT URN = %s" % gid.get_urn()\r