cosmetic
[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     logger.info('user keyid: %s' % user_keyid)  \r
162     logger.info('principal keyid: %s' % principal_keyid)        \r
163     logger.info('tool keyid: %s' % tool_keyid)  \r
164     logger.info('subject keyid: %s' % subject_keyid) \r
165     logger.info('role: %s' % role) \r
166     logger.info('user gid: %s' % user_gid.dump_string())\r
167     f = open('/tmp/speaksfor/tool.gid', 'w')\r
168     f.write(tool_gid.dump_string())\r
169     f.close()   \r
170 \r
171     # Credential must pass xmlsec1 verify\r
172     cred_file = write_to_tempfile(cred.save_to_string())\r
173     cert_args = []\r
174     if trusted_roots:\r
175         for x in trusted_roots:\r
176             cert_args += ['--trusted-pem', x.filename]\r
177     # FIXME: Why do we not need to specify the --node-id option as credential.py does?\r
178     xmlsec1_args = [cred.xmlsec_path, '--verify'] + cert_args + [ cred_file]\r
179     output = run_subprocess(xmlsec1_args, stdout=None, stderr=subprocess.PIPE)\r
180     os.unlink(cred_file)\r
181     if output != 0:\r
182         # FIXME\r
183         # xmlsec errors have a msg= which is the interesting bit.\r
184         # But does this go to stderr or stdout? Do we have it here?\r
185         mstart = verified.find("msg=")\r
186         msg = ""\r
187         if mstart > -1 and len(verified) > 4:\r
188             mstart = mstart + 4\r
189             mend = verified.find('\\', mstart)\r
190             msg = verified[mstart:mend]\r
191         if msg == "":\r
192             msg = output\r
193         return False, None, "ABAC credential failed to xmlsec1 verify: %s" % msg\r
194 \r
195     # Must say U.speaks_for(U)<-T\r
196     if user_keyid != principal_keyid or \\r
197             tool_keyid != subject_keyid or \\r
198             role != ('speaks_for_%s' % user_keyid):\r
199         return False, None, "ABAC statement doesn't assert U.speaks_for(U)<-T (%s)" % cred.get_summary_tostring()\r
200 \r
201     # If schema provided, validate against schema\r
202     if HAVELXML and schema and os.path.exists(schema):\r
203         from lxml import etree\r
204         tree = etree.parse(StringIO(cred.xml))\r
205         schema_doc = etree.parse(schema)\r
206         xmlschema = etree.XMLSchema(schema_doc)\r
207         if not xmlschema.validate(tree):\r
208             error = xmlschema.error_log.last_error\r
209             message = "%s: %s (line %s)" % (cred.get_summary_tostring(), error.message, error.line)\r
210             return False, None, ("XML Credential schema invalid: %s" % message)\r
211 \r
212     if trusted_roots:\r
213         # User certificate must validate against trusted roots\r
214         try:\r
215             user_gid.verify_chain(trusted_roots)\r
216         except Exception, e:\r
217             return False, None, \\r
218                 "Cred signer (user) cert not trusted: %s" % e\r
219 \r
220         # Tool certificate must validate against trusted roots\r
221         try:\r
222             tool_gid.verify_chain(trusted_roots)\r
223         except Exception, e:\r
224             return False, None, \\r
225                 "Tool cert not trusted: %s" % e\r
226 \r
227     return True, user_gid, ""\r
228 \r
229 # Determine if this is a speaks-for context. If so, validate\r
230 # And return either the tool_cert (not speaks-for or not validated)\r
231 # or the user cert (validated speaks-for)\r
232 #\r
233 # credentials is a list of GENI-style credentials:\r
234 #  Either a cred string xml string, or Credential object of a tuple\r
235 #    [{'geni_type' : geni_type, 'geni_value : cred_value, \r
236 #      'geni_version' : version}]\r
237 # caller_gid is the raw X509 cert gid\r
238 # options is the dictionary of API-provided options\r
239 # trusted_roots is a list of Certificate objects from the system\r
240 #   trusted_root directory\r
241 # Optionally, provide an XML schema against which to validate the credential\r
242 def determine_speaks_for(logger, credentials, caller_gid, options,\r
243                          trusted_roots, schema=None):\r
244     logger.info(options)\r
245     logger.info("geni speaking for:%s " % 'geni_speaking_for' in options)  \r
246     if options and 'geni_speaking_for' in options:\r
247         speaking_for_urn = options['geni_speaking_for'].strip()\r
248         for cred in credentials:\r
249             # Skip things that aren't ABAC credentials\r
250             if type(cred) == dict:\r
251                 if cred['geni_type'] != ABACCredential.ABAC_CREDENTIAL_TYPE: continue\r
252                 cred_value = cred['geni_value']\r
253             elif isinstance(cred, Credential):\r
254                 if not isinstance(cred, ABACCredential):\r
255                     continue\r
256                 else:\r
257                     cred_value = cred\r
258             else:\r
259                 if CredentialFactory.getType(cred) != ABACCredential.ABAC_CREDENTIAL_TYPE: continue\r
260                 cred_value = cred\r
261 \r
262             # If the cred_value is xml, create the object\r
263             if not isinstance(cred_value, ABACCredential):\r
264                 cred = CredentialFactory.createCred(cred_value)\r
265 \r
266 #            print "Got a cred to check speaksfor for: %s" % cred.get_summary_tostring()\r
267 #            #cred.dump(True, True)\r
268 #            print "Caller: %s" % caller_gid.dump_string(2, True)\r
269             logger.info(cred.dump_string())\r
270             f = open('/tmp/speaksfor/%s.cred' % cred, 'w')\r
271             f.write(cred.xml)\r
272             f.close()\r
273             # See if this is a valid speaks_for\r
274             is_valid_speaks_for, user_gid, msg = \\r
275                 verify_speaks_for(cred,\r
276                                   caller_gid, speaking_for_urn, \\r
277                                       trusted_roots, schema, logger=logger)\r
278             logger.info(msg)\r
279             if is_valid_speaks_for:\r
280                 return user_gid # speaks-for\r
281             else:\r
282                 if logger:\r
283                     logger.info("Got speaks-for option but not a valid speaks_for with this credential: %s" % msg)\r
284                 else:\r
285                     print "Got a speaks-for option but not a valid speaks_for with this credential: " + msg\r
286     return caller_gid # Not speaks-for\r
287 \r
288 # Create an ABAC Speaks For credential using the ABACCredential object and it's encode&sign methods\r
289 def create_sign_abaccred(tool_gid, user_gid, ma_gid, user_key_file, cred_filename, dur_days=365):\r
290     print "Creating ABAC SpeaksFor using ABACCredential...\n"\r
291     # Write out the user cert\r
292     from tempfile import mkstemp\r
293     ma_str = ma_gid.save_to_string()\r
294     user_cert_str = user_gid.save_to_string()\r
295     if not user_cert_str.endswith(ma_str):\r
296         user_cert_str += ma_str\r
297     fp, user_cert_filename = mkstemp(suffix='cred', text=True)\r
298     fp = os.fdopen(fp, "w")\r
299     fp.write(user_cert_str)\r
300     fp.close()\r
301 \r
302     # Create the cred\r
303     cred = ABACCredential()\r
304     cred.set_issuer_keys(user_key_file, user_cert_filename)\r
305     tool_urn = tool_gid.get_urn()\r
306     user_urn = user_gid.get_urn()\r
307     user_keyid = get_cert_keyid(user_gid)\r
308     tool_keyid = get_cert_keyid(tool_gid)\r
309     cred.head = ABACElement(user_keyid, user_urn, "speaks_for_%s" % user_keyid)\r
310     cred.tails.append(ABACElement(tool_keyid, tool_urn))\r
311     cred.set_expiration(datetime.datetime.utcnow() + datetime.timedelta(days=dur_days))\r
312     cred.expiration = cred.expiration.replace(microsecond=0)\r
313 \r
314     # Produce the cred XML\r
315     cred.encode()\r
316 \r
317     # Sign it\r
318     cred.sign()\r
319     # Save it\r
320     cred.save_to_file(cred_filename)\r
321     print "Created ABAC credential: '%s' in file %s" % \\r
322             (cred.get_summary_tostring(), cred_filename)\r
323 \r
324 # FIXME: Assumes xmlsec1 is on path\r
325 # FIXME: Assumes signer is itself signed by an 'ma_gid' that can be trusted\r
326 def create_speaks_for(tool_gid, user_gid, ma_gid, \\r
327                           user_key_file, cred_filename, dur_days=365):\r
328     tool_urn = tool_gid.get_urn()\r
329     user_urn = user_gid.get_urn()\r
330 \r
331     header = '<?xml version="1.0" encoding="UTF-8"?>'\r
332     reference = "ref0"\r
333     signature_block = \\r
334         '<signatures>\n' + \\r
335         signature_template + \\r
336         '</signatures>'\r
337     template = header + '\n' + \\r
338         '<signed-credential '\r
339     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
340     template += '>\n' + \\r
341         '<credential xml:id="%s">\n' + \\r
342         '<type>abac</type>\n' + \\r
343         '<serial/>\n' +\\r
344         '<owner_gid/>\n' + \\r
345         '<owner_urn/>\n' + \\r
346         '<target_gid/>\n' + \\r
347         '<target_urn/>\n' + \\r
348         '<uuid/>\n' + \\r
349         '<expires>%s</expires>' +\\r
350         '<abac>\n' + \\r
351         '<rt0>\n' + \\r
352         '<version>%s</version>\n' + \\r
353         '<head>\n' + \\r
354         '<ABACprincipal><keyid>%s</keyid><mnemonic>%s</mnemonic></ABACprincipal>\n' +\\r
355         '<role>speaks_for_%s</role>\n' + \\r
356         '</head>\n' + \\r
357         '<tail>\n' +\\r
358         '<ABACprincipal><keyid>%s</keyid><mnemonic>%s</mnemonic></ABACprincipal>\n' +\\r
359         '</tail>\n' +\\r
360         '</rt0>\n' + \\r
361         '</abac>\n' + \\r
362         '</credential>\n' + \\r
363         signature_block + \\r
364         '</signed-credential>\n'\r
365 \r
366 \r
367     credential_duration = datetime.timedelta(days=dur_days)\r
368     expiration = datetime.datetime.now(du_tz.tzutc()) + credential_duration\r
369     expiration_str = expiration.strftime('%Y-%m-%dT%H:%M:%SZ') # FIXME: libabac can't handle .isoformat()\r
370     version = "1.1"\r
371 \r
372     user_keyid = get_cert_keyid(user_gid)\r
373     tool_keyid = get_cert_keyid(tool_gid)\r
374     unsigned_cred = template % (reference, expiration_str, version, \\r
375                                     user_keyid, user_urn, user_keyid, tool_keyid, tool_urn, \\r
376                                     reference, reference)\r
377     unsigned_cred_filename = write_to_tempfile(unsigned_cred)\r
378 \r
379     # Now sign the file with xmlsec1\r
380     # xmlsec1 --sign --privkey-pem privkey.pem,cert.pem \r
381     # --output signed.xml tosign.xml\r
382     pems = "%s,%s,%s" % (user_key_file, user_gid.get_filename(),\r
383                          ma_gid.get_filename())\r
384     # FIXME: assumes xmlsec1 is on path\r
385     cmd = ['xmlsec1',  '--sign',  '--privkey-pem', pems, \r
386            '--output', cred_filename, unsigned_cred_filename]\r
387 \r
388 #    print " ".join(cmd)\r
389     sign_proc_output = run_subprocess(cmd, stdout=subprocess.PIPE, stderr=None)\r
390     if sign_proc_output == None:\r
391         print "OUTPUT = %s" % sign_proc_output\r
392     else:\r
393         print "Created ABAC credential: '%s speaks_for %s' in file %s" % \\r
394             (tool_urn, user_urn, cred_filename)\r
395     os.unlink(unsigned_cred_filename)\r
396 \r
397 \r
398 # Test procedure\r
399 if __name__ == "__main__":\r
400 \r
401     parser = optparse.OptionParser()\r
402     parser.add_option('--cred_file', \r
403                       help='Name of credential file')\r
404     parser.add_option('--tool_cert_file', \r
405                       help='Name of file containing tool certificate')\r
406     parser.add_option('--user_urn', \r
407                       help='URN of speaks-for user')\r
408     parser.add_option('--user_cert_file', \r
409                       help="filename of x509 certificate of signing user")\r
410     parser.add_option('--ma_cert_file', \r
411                       help="filename of x509 cert of MA that signed user cert")\r
412     parser.add_option('--user_key_file', \r
413                       help="filename of private key of signing user")\r
414     parser.add_option('--trusted_roots_directory', \r
415                       help='Directory of trusted root certs')\r
416     parser.add_option('--create',\r
417                       help="name of file of ABAC speaksfor cred to create")\r
418     parser.add_option('--useObject', action='store_true', default=False,\r
419                       help='Use the ABACCredential object to create the credential (default False)')\r
420 \r
421     options, args = parser.parse_args(sys.argv)\r
422 \r
423     tool_gid = GID(filename=options.tool_cert_file)\r
424 \r
425     if options.create:\r
426         if options.user_cert_file and options.user_key_file \\r
427             and options.ma_cert_file:\r
428             user_gid = GID(filename=options.user_cert_file)\r
429             ma_gid = GID(filename=options.ma_cert_file)\r
430             if options.useObject:\r
431                 create_sign_abaccred(tool_gid, user_gid, ma_gid, \\r
432                                          options.user_key_file,  \\r
433                                          options.create)\r
434             else:\r
435                 create_speaks_for(tool_gid, user_gid, ma_gid, \\r
436                                          options.user_key_file,  \\r
437                                          options.create)\r
438         else:\r
439             print "Usage: --create cred_file " + \\r
440                 "--user_cert_file user_cert_file" + \\r
441                 " --user_key_file user_key_file --ma_cert_file ma_cert_file"\r
442         sys.exit()\r
443 \r
444     user_urn = options.user_urn\r
445 \r
446     # Get list of trusted rootcerts\r
447     if options.cred_file and not options.trusted_roots_directory:\r
448         sys.exit("Must supply --trusted_roots_directory to validate a credential")\r
449 \r
450     trusted_roots_directory = options.trusted_roots_directory\r
451     trusted_roots = \\r
452         [Certificate(filename=os.path.join(trusted_roots_directory, file)) \\r
453              for file in os.listdir(trusted_roots_directory) \\r
454              if file.endswith('.pem') and file != 'CATedCACerts.pem']\r
455 \r
456     cred = open(options.cred_file).read()\r
457 \r
458     creds = [{'geni_type' : ABACCredential.ABAC_CREDENTIAL_TYPE, 'geni_value' : cred, \r
459               'geni_version' : '1'}]\r
460     gid = determine_speaks_for(None, creds, tool_gid, \\r
461                                    {'geni_speaking_for' : user_urn}, \\r
462                                    trusted_roots)\r
463 \r
464 \r
465     print 'SPEAKS_FOR = %s' % (gid != tool_gid)\r
466     print "CERT URN = %s" % gid.get_urn()\r