open() instead of file()
[sfa.git] / sfa / client / sfaclientlib.py
1 # Thierry Parmentelat -- INRIA
2 """
3 a minimal library for writing "lightweight" SFA clients
4 """
5
6 from __future__ import print_function
7
8 # xxx todo
9 # this library should probably check for the expiration date of the various
10 # certificates and automatically retrieve fresh ones when expired
11
12 import sys
13 import os, os.path
14 import subprocess
15 from datetime import datetime
16 from sfa.util.xrn import Xrn
17
18 import sfa.util.sfalogging
19 # importing sfa.utils.faults does pull a lot of stuff 
20 # OTOH it's imported from Certificate anyways, so..
21 from sfa.util.faults import RecordNotFound
22
23 from sfa.client.sfaserverproxy import SfaServerProxy
24
25 # see optimizing dependencies below
26 from sfa.trust.certificate import Keypair, Certificate
27 from sfa.trust.credential import Credential
28 from sfa.trust.gid import GID
29 ########## 
30 # a helper class to implement the bootstrapping of cryptoa. material
31 # assuming we are starting from scratch on the client side 
32 # what's needed to complete a full slice creation cycle
33 # (**) prerequisites: 
34 #  (*) a local private key 
35 #  (*) the corresp. public key in the registry 
36 # (**) step1: a self-signed certificate
37 #      default filename is <hrn>.sscert
38 # (**) step2: a user credential
39 #      obtained at the registry with GetSelfCredential
40 #      using the self-signed certificate as the SSL cert
41 #      default filename is <hrn>.user.cred
42 # (**) step3: a registry-provided certificate (i.e. a GID)
43 #      obtained at the registry using Resolve
44 #      using the step2 credential as credential
45 #      default filename is <hrn>.user.gid
46 #
47 # From that point on, the GID is used as the SSL certificate
48 # and the following can be done
49 #
50 # (**) retrieve a slice (or authority) credential 
51 #      obtained at the registry with GetCredential
52 #      using the (step2) user-credential as credential
53 #      default filename is <hrn>.<type>.cred
54 # (**) retrieve a slice (or authority) GID 
55 #      obtained at the registry with Resolve
56 #      using the (step2) user-credential as credential
57 #      default filename is <hrn>.<type>.cred
58 #
59 # (**) additionnally, it might make sense to upgrade a GID file 
60 # into a pkcs12 certificate usable in a browser
61 # this bundled format allows for embedding the private key
62
63
64 ########## Implementation notes
65 #
66 # (*) decorators
67 #
68 # this implementation is designed as a guideline for 
69 # porting to other languages
70 #
71 # the decision to go for decorators aims at focusing 
72 # on the core of what needs to be done when everything
73 # works fine, and to take caching and error management 
74 # out of the way
75
76 # for non-pythonic developers, it should be enough to 
77 # implement the bulk of this code, namely the _produce methods
78 # and to add caching and error management by whichever means 
79 # is available, including inline 
80 #
81 # (*) self-signed certificates
82
83 # still with other languages in mind, we've tried to keep the
84 # dependencies to the rest of the code as low as possible
85
86 # however this still relies on the sfa.trust.certificate module
87 # for the initial generation of a self-signed-certificate that
88 # is associated to the user's ssh-key
89 # (for user-friendliness, and for smooth operations with planetlab, 
90 # the usage model is to reuse an existing keypair)
91
92 # there might be a more portable, i.e. less language-dependant way, to
93 # implement this step by exec'ing the openssl command.
94 # a known successful attempt at this approach that worked 
95 # for Java is documented below
96 # http://nam.ece.upatras.gr/fstoolkit/trac/wiki/JavaSFAClient
97 #
98 # (*) pkcs12
99
100 # the implementation of the pkcs12 wrapping, which is a late addition,
101 # is done through direct calls to openssl
102 #
103 ####################
104
105 class SfaClientException(Exception): pass
106
107 class SfaClientBootstrap:
108
109     # dir is mandatory but defaults to '.'
110     def __init__(self, user_hrn, registry_url, dir=None, 
111                  verbose=False, timeout=None, logger=None):
112         self.hrn = user_hrn
113         self.registry_url = registry_url
114         if dir is None:
115             dir="."
116         self.dir = dir
117         self.verbose = verbose
118         self.timeout = timeout
119         # default for the logger is to use the global sfa logger
120         if logger is None: 
121             logger = sfa.util.sfalogging.logger
122         self.logger = logger
123
124     ######################################## *_produce methods
125     ### step1
126     # unconditionnally create a self-signed certificate
127     def self_signed_cert_produce(self, output):
128         self.assert_private_key()
129         private_key_filename = self.private_key_filename()
130         keypair = Keypair(filename=private_key_filename)
131         self_signed = Certificate(subject = self.hrn)
132         self_signed.set_pubkey(keypair)
133         self_signed.set_issuer(keypair, self.hrn)
134         self_signed.sign()
135         self_signed.save_to_file(output)
136         self.logger.debug("SfaClientBootstrap: Created self-signed certificate for {} in {}"
137                           .format(self.hrn, output))
138         return output
139
140     ### step2 
141     # unconditionnally retrieve my credential (GetSelfCredential)
142     # we always use the self-signed-cert as the SSL cert
143     def my_credential_produce(self, output):
144         self.assert_self_signed_cert()
145         certificate_filename = self.self_signed_cert_filename()
146         certificate_string = self.plain_read(certificate_filename)
147         self.assert_private_key()
148         registry_proxy = SfaServerProxy(self.registry_url,
149                                         self.private_key_filename(),
150                                         certificate_filename)
151         try:
152             credential_string = registry_proxy.GetSelfCredential(certificate_string, self.hrn, "user")
153         except:
154             # some urns hrns may replace non hierarchy delimiters '.' with an '_' instead of escaping the '.'
155             hrn = Xrn(self.hrn).get_hrn().replace('\.', '_') 
156             credential_string = registry_proxy.GetSelfCredential(certificate_string, hrn, "user")
157         self.plain_write(output, credential_string)
158         self.logger.debug("SfaClientBootstrap: Wrote result of GetSelfCredential in {}".format(output))
159         return output
160
161     ### step3
162     # unconditionnally retrieve my GID - use the general form 
163     def my_gid_produce(self, output):
164         return self.gid_produce(output, self.hrn, "user")
165
166     ### retrieve any credential (GetCredential) unconditionnal form
167     # we always use the GID as the SSL cert
168     def credential_produce(self, output, hrn, type):
169         self.assert_my_gid()
170         certificate_filename = self.my_gid_filename()
171         self.assert_private_key()
172         registry_proxy = SfaServerProxy(self.registry_url, self.private_key_filename(),
173                                         certificate_filename)
174         self.assert_my_credential()
175         my_credential_string = self.my_credential_string()
176         credential_string = registry_proxy.GetCredential(my_credential_string, hrn, type)
177         self.plain_write(output, credential_string)
178         self.logger.debug("SfaClientBootstrap: Wrote result of GetCredential in {}".format(output))
179         return output
180
181     def slice_credential_produce(self, output, hrn):
182         return self.credential_produce(output, hrn, "slice")
183
184     def authority_credential_produce(self, output, hrn):
185         return self.credential_produce(output, hrn, "authority")
186
187     ### retrieve any gid(Resolve) - unconditionnal form
188     # use my GID when available as the SSL cert, otherwise the self-signed
189     def gid_produce(self, output, hrn, type ):
190         try:
191             self.assert_my_gid()
192             certificate_filename = self.my_gid_filename()
193         except:
194             self.assert_self_signed_cert()
195             certificate_filename = self.self_signed_cert_filename()
196             
197         self.assert_private_key()
198         registry_proxy = SfaServerProxy(self.registry_url, self.private_key_filename(),
199                                         certificate_filename)
200         credential_string = self.plain_read(self.my_credential())
201         records = registry_proxy.Resolve(hrn, credential_string)
202         records = [record for record in records if record['type'] == type]
203         if not records:
204             raise RecordNotFound("hrn {} ({}) unknown to registry {}".format(hrn, type, self.registry_url))
205         record = records[0]
206         self.plain_write(output, record['gid'])
207         self.logger.debug("SfaClientBootstrap: Wrote GID for {} ({}) in {}".format(hrn, type, output))
208         return output
209
210
211 # http://trac.myslice.info/wiki/MySlice/Developer/SFALogin
212 ### produce a pkcs12 bundled certificate from GID and private key
213 # xxx for now we put a hard-wired password that's just, well, 'password'
214 # when leaving this empty on the mac, result can't seem to be loaded in keychain..
215     def my_pkcs12_produce(self, filename):
216         password = raw_input("Enter password for p12 certificate: ")
217         openssl_command =  ['openssl', 'pkcs12', "-export"]
218         openssl_command += [ "-password", "pass:{}".format(password) ]
219         openssl_command += [ "-inkey", self.private_key_filename()]
220         openssl_command += [ "-in",    self.my_gid_filename()]
221         openssl_command += [ "-out",   filename ]
222         if subprocess.call(openssl_command) == 0:
223             print("Successfully created {}".format(filename))
224         else:
225             print("Failed to create {}".format(filename))
226
227     # Returns True if credential file is valid. Otherwise return false.
228     def validate_credential(self, filename):
229         valid = True
230         cred = Credential(filename=filename)
231         # check if credential is expires
232         if cred.get_expiration() < datetime.utcnow():
233             valid = False
234         return valid
235     
236
237     #################### public interface
238     
239     # return my_gid, run all missing steps in the bootstrap sequence
240     def bootstrap_my_gid(self):
241         self.self_signed_cert()
242         self.my_credential()
243         return self.my_gid()
244
245     # once we've bootstrapped we can use this object to issue any other SFA call
246     # always use my gid
247     def server_proxy(self, url):
248         self.assert_my_gid()
249         return SfaServerProxy(url, self.private_key_filename(), self.my_gid_filename(),
250                               verbose=self.verbose, timeout=self.timeout)
251
252     # now in some cases the self-signed is enough
253     def server_proxy_simple(self, url):
254         self.assert_self_signed_cert()
255         return SfaServerProxy(url, self.private_key_filename(), self.self_signed_cert_filename(),
256                               verbose=self.verbose, timeout=self.timeout)
257
258     # this method can optionnally be invoked to ensure proper
259     # installation of the private key that belongs to this user
260     # installs private_key in working dir with expected name -- preserve mode
261     # typically user_private_key would be ~/.ssh/id_rsa
262     # xxx should probably check the 2 files are identical
263     def init_private_key_if_missing(self, user_private_key):
264         private_key_filename = self.private_key_filename()
265         if not os.path.isfile(private_key_filename):
266             key = self.plain_read(user_private_key)
267             self.plain_write(private_key_filename, key)
268             os.chmod(private_key_filename, os.stat(user_private_key).st_mode)
269             self.logger.debug("SfaClientBootstrap: Copied private key from {} into {}"
270                               .format(user_private_key, private_key_filename))
271         
272     #################### private details
273     # stupid stuff
274     def fullpath(self, file):
275         return os.path.join(self.dir, file)
276
277     # the expected filenames for the various pieces
278     def private_key_filename(self): 
279         return self.fullpath("{}.pkey".format(Xrn.unescape(self.hrn)))
280     def self_signed_cert_filename(self): 
281         return self.fullpath("{}.sscert".format(self.hrn))
282     def my_credential_filename(self):
283         return self.credential_filename(self.hrn, "user")
284     # the tests use sfi -u <pi-user>; meaning that the slice credential filename
285     # needs to keep track of the user too
286     def credential_filename(self, hrn, type): 
287         if type in ['user']:
288             basename = "{}.{}.cred".format(hrn, type)
289         else:
290             basename = "{}-{}.{}.cred".format(self.hrn, hrn, type)
291         return self.fullpath(basename)
292     def slice_credential_filename(self, hrn): 
293         return self.credential_filename(hrn, 'slice')
294     def authority_credential_filename(self, hrn): 
295         return self.credential_filename(hrn, 'authority')
296     def my_gid_filename(self):
297         return self.gid_filename(self.hrn, "user")
298     def gid_filename(self, hrn, type): 
299         return self.fullpath("{}.{}.gid".format(hrn, type))
300     def my_pkcs12_filename(self):
301         return self.fullpath("{}.p12".format(self.hrn))
302
303 # optimizing dependencies
304 # originally we used classes GID or Credential or Certificate 
305 # like e.g. 
306 #        return Credential(filename=self.my_credential()).save_to_string()
307 # but in order to make it simpler to other implementations/languages..
308     def plain_read(self, filename):
309         with open(filename) as infile:
310             return infile.read()
311
312     def plain_write(self, filename, contents):
313         with open(filename, "w") as outfile:
314             outfile.write(contents)
315
316     def assert_filename(self, filename, kind):
317         if not os.path.isfile(filename):
318             raise IOError("Missing {} file {}".format(kind, filename))
319         return True
320         
321     def assert_private_key(self):
322         return self.assert_filename(self.private_key_filename(), "private key")
323     def assert_self_signed_cert(self):
324         return self.assert_filename(self.self_signed_cert_filename(), "self-signed certificate")
325     def assert_my_credential(self):
326         return self.assert_filename(self.my_credential_filename(), "user's credential")
327     def assert_my_gid(self):
328         return self.assert_filename(self.my_gid_filename(), "user's GID")
329
330
331     # decorator to make up the other methods
332     def get_or_produce(filename_method, produce_method, validate_method=None):
333         # default validator returns true
334         def wrap(f):
335             def wrapped(self, *args, **kw):
336                 filename = filename_method(self, *args, **kw)
337                 if os.path.isfile(filename):
338                     if not validate_method:
339                         return filename
340                     elif validate_method(self, filename): 
341                         return filename
342                     else:
343                         # remove invalid file
344                         self.logger.warning("Removing {} - has expired".format(filename))
345                         os.unlink(filename) 
346                 try:
347                     produce_method(self, filename, *args, **kw)
348                     return filename
349                 except IOError:
350                     raise 
351                 except :
352                     error = sys.exc_info()[:2]
353                     message = "Could not produce/retrieve {} ({} -- {})"\
354                               .format(filename, error[0], error[1])
355                     self.logger.log_exc(message)
356                     raise Exception(message)
357             return wrapped
358         return wrap
359
360     @get_or_produce(self_signed_cert_filename, self_signed_cert_produce)
361     def self_signed_cert(self): pass
362
363     @get_or_produce(my_credential_filename, my_credential_produce, validate_credential)
364     def my_credential(self): pass
365
366     @get_or_produce(my_gid_filename, my_gid_produce)
367     def my_gid(self): pass
368
369     @get_or_produce(my_pkcs12_filename, my_pkcs12_produce)
370     def my_pkcs12(self): pass
371
372     @get_or_produce(credential_filename, credential_produce, validate_credential)
373     def credential(self, hrn, type): pass
374
375     @get_or_produce(slice_credential_filename, slice_credential_produce, validate_credential)
376     def slice_credential(self, hrn): pass
377
378     @get_or_produce(authority_credential_filename, authority_credential_produce, validate_credential)
379     def authority_credential(self, hrn): pass
380
381     @get_or_produce(gid_filename, gid_produce)
382     def gid(self, hrn, type ): pass
383
384
385     # get the credentials as strings, for inserting as API arguments
386     def my_credential_string(self): 
387         self.my_credential()
388         return self.plain_read(self.my_credential_filename())
389     def slice_credential_string(self, hrn): 
390         self.slice_credential(hrn)
391         return self.plain_read(self.slice_credential_filename(hrn))
392     def authority_credential_string(self, hrn): 
393         self.authority_credential(hrn)
394         return self.plain_read(self.authority_credential_filename(hrn))
395
396     # for consistency
397     def private_key(self):
398         self.assert_private_key()
399         return self.private_key_filename()
400
401     def delegate_credential_string(self, original_credential, to_hrn, to_type='authority'):
402         """
403         sign a delegation credential to someone else
404
405         original_credential : typically one's user- or slice- credential to be delegated to s/b else
406         to_hrn : the hrn of the person that will be allowed to do stuff on our behalf
407         to_type : goes with to_hrn, usually 'user' or 'authority'
408
409         returns a string with the delegated credential
410
411         this internally uses self.my_gid()
412         it also retrieves the gid for to_hrn/to_type
413         and uses Credential.delegate()"""
414
415         # the gid and hrn of the object we are delegating
416         if isinstance(original_credential, str):
417             original_credential = Credential(string=original_credential)
418         original_gid = original_credential.get_gid_object()
419         original_hrn = original_gid.get_hrn()
420
421         if not original_credential.get_privileges().get_all_delegate():
422             self.logger.error("delegate_credential_string: original credential {} does not have delegate bit set"
423                               .format(original_hrn))
424             return
425
426         # the delegating user's gid
427         my_gid = self.my_gid()
428
429         # retrieve the GID for the entity that we're delegating to
430         to_gidfile = self.gid(to_hrn, to_type)
431 #        to_gid = GID(to_gidfile )
432 #        to_hrn = delegee_gid.get_hrn()
433 #        print 'to_hrn', to_hrn
434         delegated_credential = original_credential.delegate(to_gidfile, self.private_key(), my_gid)
435         return delegated_credential.save_to_string(save_parents=True)